feat(llm): support hami (#25216)

This commit is contained in:
Zexi Li
2026-07-23 17:08:56 +08:00
committed by GitHub
parent d75d58f42e
commit 62d61ee89d
39 changed files with 1221 additions and 266 deletions
+1
View File
@@ -17,6 +17,7 @@ type InstantModelListInput struct {
ModelTag string `json:"model_tag"`
ModelId string `json:"model_id"`
LlmType string `json:"llm_type"`
Source string `json:"source"`
Image string `json:"image"`
Mounts string `json:"mounts"`
+1 -1
View File
@@ -8,5 +8,5 @@ const (
LLM_SGLANG_HF_ENDPOINT = LLM_VLLM_HF_ENDPOINT
LLM_SGLANG_CACHE_DIR = "/root/.cache/huggingface"
LLM_SGLANG_BASE_PATH = "/data/models"
LLM_SGLANG_MODELS_PATH = "/data/models/huggingface"
LLM_SGLANG_MODELS_PATH = LLM_SGLANG_BASE_PATH
)
+12 -7
View File
@@ -78,9 +78,14 @@ func (s PortMappings) IsZero() bool {
}
type Device struct {
DevType string `json:"dev_type"`
Model string `json:"model"`
DevicePath string `json:"device_path"`
DevType string `json:"dev_type"`
SharingMode string `json:"sharing_mode,omitempty"`
Model string `json:"model"`
DevicePath string `json:"device_path"`
// MemoryMb is optional per-device VRAM (MiB) for HAMI. When > 0 it is used
// as MemoryMb/MemoryRequest on pod create; otherwise claim is split evenly.
MemoryMb int `json:"memory_mb,omitempty"`
SmUtilLimit int `json:"sm_util_limit,omitempty"`
}
type Devices []Device
@@ -139,6 +144,10 @@ type LLMSkuDetails struct {
// Inference backend version and parameters
BackendVersion string `json:"backend_version"`
BackendParameters []string `json:"backend_parameters,omitempty"`
// VramClaimMb is computed from mounted InstantModel weight_size_bytes
// (EstimateClaimMb). Not persisted on the SKU row.
VramClaimMb int `json:"vram_claim_mb"`
}
type MountedAppResourceDetails struct {
@@ -151,10 +160,6 @@ type LLMSKuBaseCreateInput struct {
Cpu int `json:"cpu"`
Memory int `json:"memory"`
Bandwidth int `json:"bandwidth"`
// VramClaimMb is the estimated VRAM (MiB) the inference instance will
// require. Optional — if 0, the deployment create task will auto-fill it
// from the mounted InstantModel's weight_size_bytes.
VramClaimMb int `json:"vram_claim_mb,omitempty"`
Volumes *Volumes `json:"volumes"`
HostPaths *HostPaths `json:"host_paths"`
+1 -1
View File
@@ -8,5 +8,5 @@ const (
LLM_VLLM_HF_ENDPOINT = "https://hf-mirror.com"
LLM_VLLM_CACHE_DIR = "/root/.cache/huggingface"
LLM_VLLM_BASE_PATH = "/data/models"
LLM_VLLM_MODELS_PATH = "/data/models/huggingface"
LLM_VLLM_MODELS_PATH = LLM_VLLM_BASE_PATH
)
@@ -96,6 +96,7 @@ func (i isolatedDevice) ValidateCreateData(ctx context.Context, userCred mcclien
return nil, httperrors.NewInputParameterError("index %d is large than isolated device size %d", index, len(podDevs))
}
isoDev.Id = podDevs[index].IsolatedDeviceId
isoDev.GuestIsolatedDeviceIndex = int(podDevs[index].Index)
// remove index
isoDev.Index = nil
} else {
@@ -107,6 +108,7 @@ func (i isolatedDevice) ValidateCreateData(ctx context.Context, userCred mcclien
d := podDevs[i].GetIsolatedDevice()
if d.GetId() == isoDev.Id || d.GetName() == isoDev.Id {
isoDev.Id = d.GetId()
isoDev.GuestIsolatedDeviceIndex = int(podDevs[i].Index)
foundDisk = true
host := d.GetHost()
if host.HostType != api.HOST_TYPE_CONTAINER {
@@ -128,6 +128,7 @@ func probeNvidiaGpus(sharingMode string, manager isolated_device.IContainerDevic
}
dev := nvidiaGpuUsages[pciAddr].nvidiaGPU
dev.manager = manager
dev.SetSharingMode(sharingMode)
res = append(res, nvidiaGpuUsages[pciAddr].nvidiaGPU)
}
nvidiaGpuUsages = nil
@@ -810,6 +810,10 @@ func (dev *SBaseDevice) GetSharingMode() string {
return dev.sharingMode
}
func (dev *SBaseDevice) SetSharingMode(mode string) {
dev.sharingMode = mode
}
func (dev *SBaseDevice) GetPfName() string {
return ""
}
+1 -21
View File
@@ -88,27 +88,7 @@ func (c *comfyui) GetContainerSpec(ctx context.Context, llm *models.SLLM, image
},
}
effDevs := models.GetEffectiveDevices(llm, sku)
if len(devices) == 0 && effDevs != nil && len(*effDevs) > 0 {
for i := range *effDevs {
index := i
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Index: &index,
},
})
}
} else if len(devices) > 0 {
for i := range devices {
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Id: devices[i].Id,
},
})
}
}
appendContainerIsolatedDevices(&spec, llm, sku, devices)
// Volume Mounts, see: https://github.com/YanWenKun/ComfyUI-Docker?tab=readme-ov-file#quick-start---nvidia-gpu
diskIndex := 0
@@ -18,8 +18,12 @@ func TestAppendContainerIsolatedDevicesFromSku(t *testing.T) {
if len(spec.Devices) != 1 {
t.Fatalf("devices len = %d", len(spec.Devices))
}
if spec.Devices[0].IsolatedDevice == nil || spec.Devices[0].IsolatedDevice.Index == nil || *spec.Devices[0].IsolatedDevice.Index != 0 {
t.Fatalf("device index = %#v", spec.Devices[0].IsolatedDevice)
iso := spec.Devices[0].IsolatedDevice
if iso == nil || iso.Index == nil || *iso.Index != 0 {
t.Fatalf("device index = %#v", iso)
}
if iso.GuestIsolatedDeviceIndex != 0 {
t.Fatalf("guest_isolated_device_index = %d", iso.GuestIsolatedDeviceIndex)
}
}
@@ -31,6 +35,9 @@ func TestAppendContainerIsolatedDevicesById(t *testing.T) {
if len(spec.Devices) != 1 || spec.Devices[0].IsolatedDevice == nil || spec.Devices[0].IsolatedDevice.Id != "gpu-1" {
t.Fatalf("devices = %#v", spec.Devices)
}
if spec.Devices[0].IsolatedDevice.GuestIsolatedDeviceIndex != 0 {
t.Fatalf("guest_isolated_device_index = %d", spec.Devices[0].IsolatedDevice.GuestIsolatedDeviceIndex)
}
}
func TestDesktopUiTitle(t *testing.T) {
@@ -128,7 +128,8 @@ func appendContainerIsolatedDevices(spec *computeapi.ContainerSpec, llm *models.
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Index: &index,
Index: &index,
GuestIsolatedDeviceIndex: index,
},
})
}
@@ -138,7 +139,8 @@ func appendContainerIsolatedDevices(spec *computeapi.ContainerSpec, llm *models.
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Id: devices[i].Id,
Id: devices[i].Id,
GuestIsolatedDeviceIndex: i,
},
})
}
@@ -148,7 +148,7 @@ func downloadHuggingFaceSnapshot(
}
modelBase := filepath.Base(modelName)
localDir := filepath.Join(tmpDir, "huggingface", modelBase)
localDir := filepath.Join(tmpDir, modelBase)
if err := os.MkdirAll(localDir, 0755); err != nil {
return "", nil, errors.Wrap(err, "mkdir local model dir")
}
@@ -0,0 +1,48 @@
package llm_container
import (
"path"
"path/filepath"
"strings"
"testing"
api "yunion.io/x/onecloud/pkg/apis/llm"
)
func TestModelsPathIsFlatBase(t *testing.T) {
if api.LLM_VLLM_MODELS_PATH != api.LLM_VLLM_BASE_PATH {
t.Fatalf("LLM_VLLM_MODELS_PATH = %q, want %q", api.LLM_VLLM_MODELS_PATH, api.LLM_VLLM_BASE_PATH)
}
if api.LLM_SGLANG_MODELS_PATH != api.LLM_SGLANG_BASE_PATH {
t.Fatalf("LLM_SGLANG_MODELS_PATH = %q, want %q", api.LLM_SGLANG_MODELS_PATH, api.LLM_SGLANG_BASE_PATH)
}
if api.LLM_VLLM_MODELS_PATH != "/data/models" {
t.Fatalf("LLM_VLLM_MODELS_PATH = %q, want /data/models", api.LLM_VLLM_MODELS_PATH)
}
}
func TestImportModelFlatLayout(t *testing.T) {
tmpDir := "/tmp/import-work"
modelBase := filepath.Base("Qwen/Qwen3-0.6B")
localDir := filepath.Join(tmpDir, modelBase)
wantLocal := filepath.Join(tmpDir, "Qwen3-0.6B")
if localDir != wantLocal {
t.Fatalf("localDir = %q, want %q", localDir, wantLocal)
}
mount := path.Join(api.LLM_VLLM_MODELS_PATH, modelBase)
wantMount := "/data/models/Qwen3-0.6B"
if mount != wantMount {
t.Fatalf("mount = %q, want %q", mount, wantMount)
}
// Mirrors GetImageInternalPathMounts: TrimPrefix(BASE) → path_map key.
trimmed := strings.TrimPrefix(mount, api.LLM_VLLM_BASE_PATH)
if trimmed != "/Qwen3-0.6B" {
t.Fatalf("path_map key = %q, want /Qwen3-0.6B", trimmed)
}
mapped := path.Join(api.LLM_VLLM, trimmed)
if mapped != "vllm/Qwen3-0.6B" {
t.Fatalf("path_map value = %q, want vllm/Qwen3-0.6B", mapped)
}
}
@@ -9,17 +9,53 @@ import (
)
func TestBuildVLLMEntrypointScriptMountedModelsFlag(t *testing.T) {
sleepScript := buildVLLMEntrypointScript(false, 1, nil, nil)
sleepScript := buildVLLMEntrypointScript("", 1, nil, nil)
if !strings.Contains(sleepScript, "sleep infinity") {
t.Fatalf("expected idle script without mounted models, got %q", sleepScript)
t.Fatalf("expected idle script without model path, got %q", sleepScript)
}
serveScript := buildVLLMEntrypointScript(true, 1, nil, &api.LLMSpecVllm{PreferredModel: "Qwen3-8B"})
if strings.Contains(sleepScript, "find ") {
t.Fatalf("idle script should not find models, got %q", sleepScript)
}
nested := "/data/models/huggingface/Qwen3-8B"
serveScript := buildVLLMEntrypointScript(nested, 1, nil, &api.LLMSpecVllm{PreferredModel: "Qwen3-8B"})
if strings.Contains(serveScript, "sleep infinity") {
t.Fatalf("expected serve script with mounted models, got %q", serveScript)
t.Fatalf("expected serve script with model path, got %q", serveScript)
}
if !strings.Contains(serveScript, api.LLM_VLLM_EXEC_PATH) {
t.Fatalf("expected vllm exec in serve script, got %q", serveScript)
}
if !strings.Contains(serveScript, nested) {
t.Fatalf("expected nested model path in serve script, got %q", serveScript)
}
if strings.Contains(serveScript, "find ") {
t.Fatalf("serve script should not find under MODELS_PATH, got %q", serveScript)
}
if strings.Contains(serveScript, api.LLM_VLLM_MODELS_PATH+"'") || strings.Contains(serveScript, "mkdir -p '"+api.LLM_VLLM_MODELS_PATH) {
t.Fatalf("serve script should not select via MODELS_PATH root, got %q", serveScript)
}
}
func TestBuildSGLangEntrypointScriptNestedModelPath(t *testing.T) {
sleepScript := buildSGLangEntrypointScript("", 1, nil, nil)
if !strings.Contains(sleepScript, "sleep infinity") {
t.Fatalf("expected idle script without model path, got %q", sleepScript)
}
nested := "/data/models/huggingface/Qwen3-8B"
serveScript := buildSGLangEntrypointScript(nested, 1, nil, &api.LLMSpecSGLang{PreferredModel: "Qwen3-8B"})
if strings.Contains(serveScript, "sleep infinity") {
t.Fatalf("expected serve script with model path, got %q", serveScript)
}
if !strings.Contains(serveScript, api.LLM_SGLANG_EXEC_PATH) {
t.Fatalf("expected sglang exec in serve script, got %q", serveScript)
}
if !strings.Contains(serveScript, nested) {
t.Fatalf("expected nested model path in serve script, got %q", serveScript)
}
if strings.Contains(serveScript, "find ") {
t.Fatalf("serve script should not find under MODELS_PATH, got %q", serveScript)
}
}
func TestLocalPathSkuEnablesServeEntrypoint(t *testing.T) {
@@ -38,9 +74,13 @@ func TestLocalPathSkuEnablesServeEntrypoint(t *testing.T) {
LocalPath: "/data/models/Qwen3-8B",
}
sku.HostPaths = &hostPaths
postOverlaysLen := 0
hasMountedModels := postOverlaysLen > 0 || models.SkuHasLocalHostPathModel(sku)
if !hasMountedModels {
t.Fatal("expected local_path sku to count as having mounted models")
paths := models.CollectContainerModelMountPaths(nil, sku)
modelPath := models.PickContainerModelMountPath(paths, "Qwen3-8B")
if modelPath != "/data/models/huggingface/Qwen3-8B" {
t.Fatalf("expected nested local_path mount, got %q", modelPath)
}
script := buildVLLMEntrypointScript(modelPath, 1, nil, nil)
if !strings.Contains(script, modelPath) {
t.Fatalf("expected script to embed mount path, got %q", script)
}
}
@@ -67,7 +67,7 @@ func downloadModelScopeSnapshot(
}
modelBase := filepath.Base(modelID)
localDir := filepath.Join(tmpDir, "modelscope", modelBase)
localDir := filepath.Join(tmpDir, modelBase)
if err := os.MkdirAll(localDir, 0755); err != nil {
return "", nil, errors.Wrap(err, "mkdir local model dir")
}
+1 -21
View File
@@ -82,27 +82,7 @@ func (o *ollama) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *
},
}
effDevs := models.GetEffectiveDevices(llm, sku)
if len(devices) == 0 && effDevs != nil && len(*effDevs) > 0 {
for i := range *effDevs {
index := i
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Index: &index,
},
})
}
} else if len(devices) > 0 {
for i := range devices {
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Id: devices[i].Id,
},
})
}
}
appendContainerIsolatedDevices(&spec, llm, sku, devices)
// // process rootfs limit
// var diskIndex *int
+13 -50
View File
@@ -222,8 +222,13 @@ func (s *sglang) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *
if sku != nil {
backendParameters = sku.BackendParameters
}
hasMountedModels := len(postOverlays) > 0 || models.SkuHasLocalHostPathModel(sku)
startScript := buildSGLangEntrypointScript(hasMountedModels, tensorParallelSize, backendParameters, effSpec)
preferred := ""
if effSpec != nil {
preferred = effSpec.PreferredModel
}
modelPath := models.PickContainerModelMountPath(models.CollectContainerModelMountPaths(llm, sku), preferred)
hasMountedModels := modelPath != "" || len(postOverlays) > 0 || models.SkuHasLocalHostPathModel(sku)
startScript := buildSGLangEntrypointScript(modelPath, tensorParallelSize, backendParameters, effSpec)
envs := []*commonapi.ContainerKeyValue{
{
Key: "HUGGING_FACE_HUB_CACHE",
@@ -249,27 +254,7 @@ func (s *sglang) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *
spec.StartupProbe = newLLMHTTPStartupProbe(api.LLM_SGLANG_DEFAULT_PORT, "/v1/models")
}
effDevs := models.GetEffectiveDevices(llm, sku)
if len(devices) == 0 && effDevs != nil && len(*effDevs) > 0 {
for i := range *effDevs {
index := i
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Index: &index,
},
})
}
} else if len(devices) > 0 {
for i := range devices {
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Id: devices[i].Id,
},
})
}
}
appendContainerIsolatedDevices(&spec, llm, sku, devices)
diskIndex := 0
ctrVols := []*commonapi.ContainerVolumeMount{
@@ -596,36 +581,14 @@ func buildSGLangServeFlags(modelPath string, tensorParallelSize int, backendPara
)
}
func buildSGLangEntrypointScript(hasMountedModels bool, tensorParallelSize int, backendParameters []string, effSpec *api.LLMSpecSGLang) string {
modelsPath := shellQuoteSingle(api.LLM_SGLANG_MODELS_PATH)
if !hasMountedModels {
return fmt.Sprintf("mkdir -p %s && exec sleep infinity", modelsPath)
func buildSGLangEntrypointScript(modelPath string, tensorParallelSize int, backendParameters []string, effSpec *api.LLMSpecSGLang) string {
modelPath = strings.TrimSpace(modelPath)
if modelPath == "" {
return "exec sleep infinity"
}
preferredPath := ""
if effSpec != nil && strings.TrimSpace(effSpec.PreferredModel) != "" {
preferredPath = path.Join(api.LLM_SGLANG_MODELS_PATH, strings.TrimSpace(effSpec.PreferredModel))
}
serveCmd := strings.Join(buildSGLangServeFlagsWithModelExpr(
`"$model"`,
`"$(basename "$model")"`,
tensorParallelSize,
backendParameters,
effSpec,
), " ")
serveCmd := strings.Join(buildSGLangServeFlags(modelPath, tensorParallelSize, backendParameters, effSpec), " ")
return strings.Join([]string{
"set -e",
fmt.Sprintf("mkdir -p %s", modelsPath),
fmt.Sprintf("preferred=%s", shellQuoteSingle(preferredPath)),
`if [ -n "$preferred" ] && [ -d "$preferred" ]; then`,
` model="$preferred"`,
"else",
fmt.Sprintf(` model="$(find %s -mindepth 1 -maxdepth 1 -type d | sort | head -n 1)"`, modelsPath),
"fi",
`if [ -z "$model" ]; then`,
` echo "no mounted SGLang model found" >&2`,
" exit 1",
"fi",
fmt.Sprintf("exec %s %s", api.LLM_SGLANG_EXEC_PATH, serveCmd),
}, "\n")
}
+13 -50
View File
@@ -198,36 +198,14 @@ func buildVLLMServeFlags(modelPath string, tensorParallelSize int, backendParame
)
}
func buildVLLMEntrypointScript(hasMountedModels bool, tensorParallelSize int, backendParameters []string, effSpec *api.LLMSpecVllm) string {
modelsPath := shellQuoteSingle(api.LLM_VLLM_MODELS_PATH)
if !hasMountedModels {
return fmt.Sprintf("mkdir -p %s && exec sleep infinity", modelsPath)
func buildVLLMEntrypointScript(modelPath string, tensorParallelSize int, backendParameters []string, effSpec *api.LLMSpecVllm) string {
modelPath = strings.TrimSpace(modelPath)
if modelPath == "" {
return "exec sleep infinity"
}
preferredPath := ""
if effSpec != nil && strings.TrimSpace(effSpec.PreferredModel) != "" {
preferredPath = path.Join(api.LLM_VLLM_MODELS_PATH, strings.TrimSpace(effSpec.PreferredModel))
}
serveCmd := strings.Join(buildVLLMServeFlagsWithModelExpr(
`"$model"`,
`"$(basename "$model")"`,
tensorParallelSize,
backendParameters,
effSpec,
), " ")
serveCmd := strings.Join(buildVLLMServeFlags(modelPath, tensorParallelSize, backendParameters, effSpec), " ")
return strings.Join([]string{
"set -e",
fmt.Sprintf("mkdir -p %s", modelsPath),
fmt.Sprintf("preferred=%s", shellQuoteSingle(preferredPath)),
`if [ -n "$preferred" ] && [ -d "$preferred" ]; then`,
` model="$preferred"`,
"else",
fmt.Sprintf(` model="$(find %s -mindepth 1 -maxdepth 1 -type d | sort | head -n 1)"`, modelsPath),
"fi",
`if [ -z "$model" ]; then`,
` echo "no mounted vLLM model found" >&2`,
" exit 1",
"fi",
fmt.Sprintf("exec %s %s", api.LLM_VLLM_EXEC_PATH, serveCmd),
}, "\n")
}
@@ -430,8 +408,13 @@ func (v *vllm) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *mo
if sku != nil {
backendParameters = sku.BackendParameters
}
hasMountedModels := len(postOverlays) > 0 || models.SkuHasLocalHostPathModel(sku)
startScript := buildVLLMEntrypointScript(hasMountedModels, tensorParallelSize, backendParameters, effSpec)
preferred := ""
if effSpec != nil {
preferred = effSpec.PreferredModel
}
modelPath := models.PickContainerModelMountPath(models.CollectContainerModelMountPaths(llm, sku), preferred)
hasMountedModels := modelPath != "" || len(postOverlays) > 0 || models.SkuHasLocalHostPathModel(sku)
startScript := buildVLLMEntrypointScript(modelPath, tensorParallelSize, backendParameters, effSpec)
envs := []*commonapi.ContainerKeyValue{
{
Key: "HUGGING_FACE_HUB_CACHE",
@@ -468,27 +451,7 @@ func (v *vllm) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *mo
}
// GPU Devices
effDevs := models.GetEffectiveDevices(llm, sku)
if len(devices) == 0 && effDevs != nil && len(*effDevs) > 0 {
for i := range *effDevs {
index := i
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Index: &index,
},
})
}
} else if len(devices) > 0 {
for i := range devices {
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE,
IsolatedDevice: &computeapi.ContainerIsolatedDevice{
Id: devices[i].Id,
},
})
}
}
appendContainerIsolatedDevices(&spec, llm, sku, devices)
// Volume Mounts
diskIndex := 0
+21 -9
View File
@@ -71,6 +71,7 @@ type SInstantModel struct {
ModelId string `width:"128" charset:"ascii" list:"user" create:"optional"`
ModelName string `width:"128" charset:"ascii" list:"user" create:"required"`
ModelTag string `width:"64" charset:"ascii" list:"user" create:"required"`
Source string `width:"32" charset:"ascii" nullable:"false" default:"huggingface" list:"user" create:"optional"`
ImageId string `width:"128" charset:"ascii" list:"user" create:"optional" update:"user"`
@@ -119,6 +120,9 @@ func (man *SInstantModelManager) ListItemFilter(
if len(input.LlmType) > 0 {
q = q.Equals("llm_type", input.LlmType)
}
if len(input.Source) > 0 {
q = q.Equals("source", input.Source)
}
if len(input.Image) > 0 {
s := auth.GetSession(ctx, userCred, options.Options.Region)
@@ -547,18 +551,23 @@ func (man *SInstantModelManager) findInstantModelByImageId(imageId string) (*SIn
}
// FindReadyInstantModel looks up an existing, ready-to-mount InstantModel for
// the given (llm_type, model_name, model_tag) triple. Returns (nil, nil) if
// none exists. Used by the deployment create flow to dedup catalog imports —
// the given (llm_type, model_name, model_tag, source) tuple. Returns (nil, nil)
// if none exists. Used by the deployment create flow to dedup catalog imports —
// if a previous deployment already brought in Qwen3-8B / vllm / main, we
// reuse that row instead of starting another download.
//
// Only enabled rows match (Enabled=true means import succeeded).
func (man *SInstantModelManager) FindReadyInstantModel(llmType, modelName, modelTag string) (*SInstantModel, error) {
// Only enabled + active rows match (Enabled=true means import succeeded;
// status=active means the glance image is still mountable).
func (man *SInstantModelManager) FindReadyInstantModel(llmType, modelName, modelTag, source string) (*SInstantModel, error) {
source = defaultInstantModelSource(source)
q := man.Query().
Equals("llm_type", llmType).
Equals("model_name", modelName).
Equals("model_tag", modelTag).
IsTrue("enabled")
Equals("source", source).
Equals("status", imageapi.IMAGE_STATUS_ACTIVE).
IsTrue("enabled").
Desc("created_at")
mdls := make([]SInstantModel, 0)
if err := db.FetchModelObjects(man, q, &mdls); err != nil {
return nil, errors.Wrap(err, "FetchModelObjects")
@@ -1091,6 +1100,7 @@ func (man *SInstantModelManager) DoImportWithParent(
tempModel.ModelName = input.ModelName
tempModel.ModelTag = input.ModelTag
tempModel.LlmType = string(input.LlmType)
tempModel.Source = defaultInstantModelSource(input.Source)
tempModel.ProjectId = userCred.GetProjectId()
if err := man.TableSpec().Insert(ctx, tempModel); err != nil {
@@ -1273,6 +1283,7 @@ func (model *SInstantModel) DoImport(ctx context.Context, userCred mcclient.Toke
"model_name": input.ModelName,
"model_tag": input.ModelTag,
"model_id": modelId,
"source": defaultInstantModelSource(input.Source),
}
// upload the image
@@ -1301,6 +1312,7 @@ func (model *SInstantModel) DoImport(ctx context.Context, userCred mcclient.Toke
model.ModelId = modelId
model.ImageId = imageId
model.Mounts = mounts
model.Source = defaultInstantModelSource(input.Source)
if shouldAutoRenameInstantModelImportName(model.Name, model.LlmType, input.ModelName, input.ModelTag) {
suffix := extractInstantModelImportNameSuffix(model.Name)
model.Name = buildInstantModelFinalName(model.LlmType, modelId, input.ModelTag, suffix)
@@ -1451,9 +1463,9 @@ func (model *SInstantModel) GetEstimatedVramSizeMb() int64 {
// GetDetailsVramRequirement is the per-row endpoint
// `GET /instant-models/{id}/vram-requirement`. It returns the heuristic VRAM
// requirement computed by the GPUStack-equivalent formula
// (weight_size * 1.2 + framework_overhead). When `weight_size_bytes` is 0
// (not yet backfilled / unknown source), `vram_required_mb` is also 0.
// requirement: weight_size * 1.2 + framework_overhead + KV reserve for the
// default max_model_len. When `weight_size_bytes` is 0 (not yet backfilled /
// unknown source), `vram_required_mb` is also 0.
func (model *SInstantModel) GetDetailsVramRequirement(
ctx context.Context,
userCred mcclient.TokenCredential,
@@ -1462,7 +1474,7 @@ func (model *SInstantModel) GetDetailsVramRequirement(
return &apis.InstantModelVramRequirement{
LlmType: model.LlmType,
WeightSizeBytes: model.WeightSizeBytes,
VramRequiredMb: vram.EstimateClaimMb(model.WeightSizeBytes, model.LlmType),
VramRequiredMb: vram.EstimateClaimMbWithContext(model.WeightSizeBytes, model.LlmType, apis.LLM_DEFAULT_CONTEXT_TOKENS),
}, nil
}
@@ -23,6 +23,20 @@ func normalizeInstantModelSource(source string, repoID string) string {
return source
}
// defaultInstantModelSource returns a stored InstantModel.source value.
// Empty input defaults to huggingface.
func defaultInstantModelSource(source string) string {
source = strings.ToLower(strings.TrimSpace(source))
switch source {
case apis.InstantModelSourceModelScope:
return apis.InstantModelSourceModelScope
case apis.InstantModelSourceHuggingFace, "":
return apis.InstantModelSourceHuggingFace
default:
return source
}
}
func resolveImportRepoAndRevision(input apis.InstantModelImportInput) (string, string, string) {
repoID := strings.TrimSpace(input.RepoId)
source := normalizeInstantModelSource(input.Source, repoID)
@@ -66,7 +80,7 @@ func buildInstantModelImportInputFromCreate(input apis.InstantModelCreateInput)
func normalizeInstantModelCreateInput(input apis.InstantModelCreateInput) apis.InstantModelCreateInput {
importInput := buildInstantModelImportInputFromCreate(input)
input.Source = importInput.Source
input.Source = defaultInstantModelSource(importInput.Source)
input.RepoId = importInput.RepoId
input.Revision = importInput.Revision
input.ModelName = importInput.ModelName
@@ -0,0 +1,25 @@
package models
import (
"testing"
apis "yunion.io/x/onecloud/pkg/apis/llm"
)
func TestDefaultInstantModelSource(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", apis.InstantModelSourceHuggingFace},
{"huggingface", apis.InstantModelSourceHuggingFace},
{"HuggingFace", apis.InstantModelSourceHuggingFace},
{"model_scope", apis.InstantModelSourceModelScope},
{"MODEL_SCOPE", apis.InstantModelSourceModelScope},
}
for _, tc := range cases {
if got := defaultInstantModelSource(tc.in); got != tc.want {
t.Fatalf("defaultInstantModelSource(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+2 -3
View File
@@ -143,9 +143,8 @@ func skuFromLLMSkuCreateInput(input *api.LLMSkuCreateInput) *SLLMSku {
Source: input.Source,
LocalPath: input.LocalPath,
SLLMSkuBase: SLLMSkuBase{
Devices: input.Devices,
VramClaimMb: input.VramClaimMb,
HostPaths: input.HostPaths,
Devices: input.Devices,
HostPaths: input.HostPaths,
},
}
if len(input.PreferHosts) > 0 {
+36 -12
View File
@@ -5,10 +5,12 @@ import (
"fmt"
"yunion.io/x/jsonutils"
"yunion.io/x/pkg/errors"
"yunion.io/x/onecloud/pkg/apis"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
)
@@ -26,6 +28,7 @@ func GetLLMBasePodCreateInput(
input *api.LLMBaseCreateInput,
llmBase *SLLMBase,
skuBase *SLLMSkuBase,
vramClaimMb int,
eip string,
) (*computeapi.ServerCreateInput, error) {
data := computeapi.ServerCreateInput{}
@@ -65,21 +68,42 @@ func GetLLMBasePodCreateInput(
effectiveDevices := getEffectiveDevices(llmBase, skuBase)
if effectiveDevices != nil && !effectiveDevices.IsZero() {
data.IsolatedDevices = make([]*computeapi.IsolatedDeviceConfig, 0)
devices := *effectiveDevices
// Evenly split the SKU's vram_claim_mb across requested devices and
// stamp it onto each request entry. Ceiling division so the sum is
// never less than the claim (1 device → claim itself; 2 devices and
// 40 GiB claim → 20 GiB each).
perDevMinMemMb := 0
if skuBase.VramClaimMb > 0 && len(devices) > 0 {
perDevMinMemMb = (skuBase.VramClaimMb + len(devices) - 1) / len(devices)
devices := make(api.Devices, len(*effectiveDevices))
copy(devices, *effectiveDevices)
for i := range devices {
normalizeLLMSkuDevice(&devices[i])
}
hasHAMINeedingClaim := false
for i := range devices {
if devices[i].SharingMode == computeapi.DEVICE_SHARING_MODE_HAMI && devices[i].MemoryMb <= 0 {
hasHAMINeedingClaim = true
break
}
}
if hasHAMINeedingClaim && vramClaimMb <= 0 {
return nil, errors.Wrap(httperrors.ErrInputParameter,
"vram claim is 0 for HAMI devices: set devices[].memory_mb, mount InstantModel with weight_size_bytes, or use a non-HAMI sharing_mode")
}
// Evenly split estimated vram claim across requested devices when a
// device does not set memory_mb. Ceiling division so the sum is never
// less than the claim.
perDevFromClaim := 0
if vramClaimMb > 0 && len(devices) > 0 {
perDevFromClaim = (vramClaimMb + len(devices) - 1) / len(devices)
}
for i := 0; i < len(devices); i++ {
memMb := devices[i].MemoryMb
if memMb <= 0 {
memMb = perDevFromClaim
}
isolatedDevice := &computeapi.IsolatedDeviceConfig{
DevType: devices[i].DevType,
Model: devices[i].Model,
DevicePath: devices[i].DevicePath,
MemoryMb: perDevMinMemMb,
DevType: devices[i].DevType,
SharingMode: devices[i].SharingMode,
Model: devices[i].Model,
DevicePath: devices[i].DevicePath,
MemoryMb: memMb,
MemoryRequest: memMb,
SmUtilLimit: devices[i].SmUtilLimit,
}
data.IsolatedDevices = append(data.IsolatedDevices, isolatedDevice)
}
+115 -23
View File
@@ -7,8 +7,11 @@ import (
"strings"
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
"yunion.io/x/pkg/utils"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/llm/options"
@@ -21,7 +24,7 @@ import (
const (
autoGpuMemoryUtilizationSafetyFactor = 1.10
autoGpuMemoryUtilizationMin = 0.05
autoGpuMemoryUtilizationMax = 0.95
autoGpuMemoryUtilizationMax = 1.0
sglangAutoGpuMemoryMetadataReserveMB = 512
)
@@ -107,6 +110,18 @@ func calculateAutoGpuMemoryUtilization(requiredVramMB int64, gpuMemoryMB int64,
return calculateAutoGpuMemoryUtilizationFromPerGPURequired(
float64(requiredVramMB)/float64(normalizeTensorParallelSize(tensorParallelSize)),
gpuMemoryMB,
false,
)
}
func calculateAutoGpuMemoryUtilizationClampMax(requiredVramMB int64, gpuMemoryMB int64, tensorParallelSize int) (float64, error) {
if requiredVramMB <= 0 {
return 0, errors.Wrap(httperrors.ErrInputParameter, "mounted model gpu_memory_required is empty")
}
return calculateAutoGpuMemoryUtilizationFromPerGPURequired(
float64(requiredVramMB)/float64(normalizeTensorParallelSize(tensorParallelSize)),
gpuMemoryMB,
true,
)
}
@@ -114,7 +129,7 @@ func instantModelEstimatedVramRequirementMB(model *SInstantModel) int64 {
if model == nil {
return 0
}
return int64(vram.EstimateClaimMb(model.WeightSizeBytes, model.LlmType))
return int64(vram.EstimateClaimMbWithContext(model.WeightSizeBytes, model.LlmType, api.LLM_DEFAULT_CONTEXT_TOKENS))
}
func runtimeArgKeyIn(key string, keys []string) bool {
@@ -275,22 +290,92 @@ func parseTokenLimitValue(value string) (int64, bool) {
return int64(math.Ceil(parsed * multiplier)), true
}
func calculateAutoGpuMemoryUtilizationFromPerGPURequired(perGPURequiredMB float64, gpuMemoryMB int64) (float64, error) {
func calculateAutoGpuMemoryUtilizationFromPerGPURequired(perGPURequiredMB float64, gpuMemoryMB int64, clampToMax bool) (float64, error) {
if gpuMemoryMB <= 0 {
return 0, errors.Wrap(httperrors.ErrInputParameter, "gpu memory_mb is empty")
}
raw := perGPURequiredMB * autoGpuMemoryUtilizationSafetyFactor / float64(gpuMemoryMB)
log.Infof("auto gpu_memory_utilization calc: perGPURequiredMB=%.2f gpuMemoryMB=%d safetyFactor=%.2f raw=%.4f clampToMax=%v max=%.2f min=%.2f",
perGPURequiredMB, gpuMemoryMB, autoGpuMemoryUtilizationSafetyFactor, raw, clampToMax, autoGpuMemoryUtilizationMax, autoGpuMemoryUtilizationMin)
if raw > autoGpuMemoryUtilizationMax {
if clampToMax {
log.Infof("auto gpu_memory_utilization clamp: raw=%.4f -> %.2f", raw, autoGpuMemoryUtilizationMax)
return autoGpuMemoryUtilizationMax, nil
}
return 0, errors.Wrapf(httperrors.ErrInputParameter,
"model requires %.2f GPU memory utilization, exceeds max %.2f", raw, autoGpuMemoryUtilizationMax)
}
if raw < autoGpuMemoryUtilizationMin {
log.Infof("auto gpu_memory_utilization raise: raw=%.4f -> %.2f", raw, autoGpuMemoryUtilizationMin)
raw = autoGpuMemoryUtilizationMin
}
return math.Ceil(raw*100) / 100, nil
result := math.Ceil(raw*100) / 100
log.Infof("auto gpu_memory_utilization result: %.2f", result)
return result, nil
}
// skuDevicesHaveHami reports whether any SKU device uses HAMI sharing after normalization.
func skuDevicesHaveHami(sku *SLLMSku) bool {
if sku == nil || sku.Devices == nil {
return false
}
for i := range *sku.Devices {
dev := (*sku.Devices)[i]
normalizeLLMSkuDevice(&dev)
if dev.SharingMode == computeapi.DEVICE_SHARING_MODE_HAMI {
return true
}
}
return false
}
// resolveHamiAllocatedMemoryMB returns the per-GPU HAMI slice size (MiB) used as
// the visible GPU memory for auto gpu-memory-utilization. Prefer device.MemoryMb;
// otherwise ceil-split EstimateVramClaimMb across devices. Returns the minimum
// across devices.
func resolveHamiAllocatedMemoryMB(sku *SLLMSku) (int64, error) {
claim := 0
if sku != nil {
claim = sku.EstimateVramClaimMb()
}
return resolveHamiAllocatedMemoryMBWithClaim(sku, claim)
}
func resolveHamiAllocatedMemoryMBWithClaim(sku *SLLMSku, claimMb int) (int64, error) {
if sku == nil || sku.Devices == nil || len(*sku.Devices) == 0 {
return 0, errors.Wrap(httperrors.ErrInputParameter, "HAMI allocated memory requires GPU devices")
}
n := len(*sku.Devices)
perDevFromClaim := 0
if claimMb > 0 && n > 0 {
perDevFromClaim = (claimMb + n - 1) / n
}
var minMem int64
for i := range *sku.Devices {
dev := (*sku.Devices)[i]
normalizeLLMSkuDevice(&dev)
mem := dev.MemoryMb
if mem <= 0 {
mem = perDevFromClaim
}
if mem <= 0 {
return 0, errors.Wrap(httperrors.ErrInputParameter,
"HAMI allocated memory is 0: set devices[].memory_mb or mount InstantModel with weight_size_bytes")
}
if minMem == 0 || int64(mem) < minMem {
minMem = int64(mem)
}
}
return minMem, nil
}
func calculateDeploymentAutoGpuMemoryUtilization(sku *SLLMSku, requiredVramMB int64, gpuMemoryMB int64, tensorParallelSize int) (float64, error) {
// HAMI visible memory is the slice (MemoryRequest / CUDA_DEVICE_MEMORY_LIMIT),
// not the physical card. Caller must pass allocated slice as gpuMemoryMB.
// When required ≈ allocated, safety factor may exceed 1.0 — clamp instead of error.
if skuDevicesHaveHami(sku) {
return calculateAutoGpuMemoryUtilizationClampMax(requiredVramMB, gpuMemoryMB, tensorParallelSize)
}
if sku != nil && api.LLMContainerType(sku.LLMType) == api.LLM_CONTAINER_SGLANG {
return calculateSGLangAutoGpuMemoryUtilization(sku, requiredVramMB, gpuMemoryMB, tensorParallelSize)
}
@@ -492,14 +577,23 @@ func BuildDeploymentResolvedGpuMemoryLLMSpec(ctx context.Context, userCred mccli
if err != nil {
return nil, err
}
gpuMemoryMB, err := minGpuMemoryMB(ctx, userCred, sku.Devices)
isHami := skuDevicesHaveHami(sku)
var gpuMemoryMB int64
if isHami {
gpuMemoryMB, err = resolveHamiAllocatedMemoryMB(sku)
} else {
gpuMemoryMB, err = minGpuMemoryMB(ctx, userCred, sku.Devices)
}
if err != nil {
return nil, err
}
log.Infof("auto gpu_memory_utilization resolve: deploy=%s sku=%s/%s llmType=%s hami=%v requiredModelVramMB=%d (no KV) gpuMemoryMB=%d tensorParallelSize=%d",
deploy.Id, sku.Id, sku.Name, sku.LLMType, isHami, requiredVramMB, gpuMemoryMB, tensorParallelSize)
utilization, err := calculateDeploymentAutoGpuMemoryUtilization(sku, requiredVramMB, gpuMemoryMB, tensorParallelSize)
if err != nil {
return nil, err
}
log.Infof("auto gpu_memory_utilization final: deploy=%s sku=%s utilization=%.2f", deploy.Id, sku.Id, utilization)
return buildAutoGpuMemoryUtilizationLLMSpec(sku, utilization)
}
@@ -512,21 +606,12 @@ func maxMountedModelVramRequirementMB(sku *SLLMSku) (int64, error) {
if len(modelIds) == 0 {
return 0, httperrors.NewInputParameterError("auto_gpu_memory_utilization requires mounted models: configure mounted_models on the LLM SKU")
}
var maxRequiredVramMB int64
for _, modelId := range modelIds {
obj, err := GetInstantModelManager().FetchById(modelId)
if err != nil {
return 0, errors.Wrapf(err, "fetch InstantModel %s", modelId)
}
requiredVramMB := instantModelEstimatedVramRequirementMB(obj.(*SInstantModel))
if requiredVramMB > maxRequiredVramMB {
maxRequiredVramMB = requiredVramMB
}
}
if maxRequiredVramMB <= 0 {
// Model weights + framework only; KV headroom belongs in HAMI slice (EstimateVramClaimMb), not util numerator.
requiredVramMB := int64(sku.EstimateModelVramMb())
if requiredVramMB <= 0 {
return 0, errors.Wrap(httperrors.ErrInputParameter, "mounted model vram requirement is empty")
}
return maxRequiredVramMB, nil
return requiredVramMB, nil
}
func minGpuMemoryMB(ctx context.Context, userCred mcclient.TokenCredential, devices *api.Devices) (int64, error) {
@@ -556,7 +641,7 @@ func fetchMinIsolatedDeviceMemoryMB(ctx context.Context, userCred mcclient.Token
}
if len(results.Data) == 0 {
return 0, errors.Wrapf(httperrors.ErrResourceNotFound,
"unused isolated device not found for %s", isolatedDeviceMemoryFilterDesc(device))
"matching isolated device not found for %s", isolatedDeviceMemoryFilterDesc(device))
}
memory, err := minIsolatedDeviceMemoryMB(results.Data)
if err != nil {
@@ -567,11 +652,18 @@ func fetchMinIsolatedDeviceMemoryMB(ctx context.Context, userCred mcclient.Token
func buildIsolatedDeviceMemoryParams(device api.Device) *jsonutils.JSONDict {
params := jsonutils.NewDict()
params.Set("unused", jsonutils.JSONTrue)
dev := device
normalizeLLMSkuDevice(&dev)
// Exclusive GPUs must be unused. Virtual sharing modes (HAMI/UNLIMITED/MPS)
// can still have free VRAM/slots while guest joins exist, so skip unused.
if !utils.IsInStringArray(dev.SharingMode, computeapi.VIRTUAL_SHARING_MODES) {
params.Set("unused", jsonutils.JSONTrue)
}
params.Set("show_baremetal_isolated_devices", jsonutils.JSONTrue)
setStringArrayParam(params, "dev_type", device.DevType)
setStringArrayParam(params, "model", device.Model)
setStringArrayParam(params, "device_path", device.DevicePath)
setStringArrayParam(params, "dev_type", dev.DevType)
setStringArrayParam(params, "sharing_mode", dev.SharingMode)
setStringArrayParam(params, "model", dev.Model)
setStringArrayParam(params, "device_path", dev.DevicePath)
return params
}
@@ -0,0 +1,168 @@
package models
import (
"math"
"testing"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
)
func TestSkuDevicesHaveHami(t *testing.T) {
hamiSku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_VLLM),
SLLMSkuBase: SLLMSkuBase{
Devices: &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI,
}},
},
}
if !skuDevicesHaveHami(hamiSku) {
t.Fatal("expected HAMI device to be detected")
}
exclusiveSku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_VLLM),
SLLMSkuBase: SLLMSkuBase{
Devices: &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.GPU_TYPE,
SharingMode: computeapi.DEVICE_SHARING_MODE_EXCLUSIVE,
}},
},
}
if skuDevicesHaveHami(exclusiveSku) {
t.Fatal("exclusive device should not be HAMI")
}
if skuDevicesHaveHami(nil) {
t.Fatal("nil sku should not be HAMI")
}
}
func TestResolveHamiAllocatedMemoryMBWithClaim(t *testing.T) {
sku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_VLLM),
SLLMSkuBase: SLLMSkuBase{
Devices: &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI,
}},
},
}
got, err := resolveHamiAllocatedMemoryMBWithClaim(sku, 4096)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 4096 {
t.Fatalf("allocated = %d, want 4096", got)
}
sku.Devices = &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI,
MemoryMb: 16384,
}}
got, err = resolveHamiAllocatedMemoryMBWithClaim(sku, 4096)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 16384 {
t.Fatalf("manual memory_mb should win, got %d", got)
}
sku.Devices = &api.Devices{
{Model: "A100", DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI, MemoryMb: 16384},
{Model: "A100", DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI},
}
got, err = resolveHamiAllocatedMemoryMBWithClaim(sku, 4096)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// second device falls back to ceil(4096/2)=2048; min is 2048
if got != 2048 {
t.Fatalf("min allocated = %d, want 2048", got)
}
sku.Devices = &api.Devices{{Model: "A100", DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI}}
if _, err := resolveHamiAllocatedMemoryMBWithClaim(sku, 0); err == nil {
t.Fatal("expected error when claim and memory_mb are both 0")
}
}
func TestCalculateDeploymentAutoGpuMemoryUtilizationHAMI(t *testing.T) {
sku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_VLLM),
SLLMSkuBase: SLLMSkuBase{
Devices: &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI,
}},
},
}
// required ≈ allocated → clamp to 1.0 (not physical 80GiB; no longer 0.95)
got, err := calculateDeploymentAutoGpuMemoryUtilization(sku, 4096, 4096, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != autoGpuMemoryUtilizationMax {
t.Fatalf("HAMI util when claim≈allocated = %v, want %v", got, autoGpuMemoryUtilizationMax)
}
// larger manual slice → util below max
got, err = calculateDeploymentAutoGpuMemoryUtilization(sku, 4096, 16384, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := math.Ceil(4096*autoGpuMemoryUtilizationSafetyFactor/16384*100) / 100
if got != want {
t.Fatalf("HAMI util with larger slice = %v, want %v", got, want)
}
if got >= autoGpuMemoryUtilizationMax {
t.Fatalf("expected util below max, got %v", got)
}
sglangSku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_SGLANG),
SLLMSkuBase: SLLMSkuBase{
Devices: &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI,
}},
},
}
got, err = calculateDeploymentAutoGpuMemoryUtilization(sglangSku, 4096, 4096, 1)
if err != nil {
t.Fatalf("unexpected sglang error: %v", err)
}
if got != autoGpuMemoryUtilizationMax {
t.Fatalf("HAMI sglang util = %v, want %v", got, autoGpuMemoryUtilizationMax)
}
}
func TestCalculateDeploymentAutoGpuMemoryUtilizationExclusive(t *testing.T) {
sku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_VLLM),
SLLMSkuBase: SLLMSkuBase{
Devices: &api.Devices{{
Model: "NVIDIA A100",
DevType: computeapi.GPU_TYPE,
SharingMode: computeapi.DEVICE_SHARING_MODE_EXCLUSIVE,
}},
},
}
got, err := calculateDeploymentAutoGpuMemoryUtilization(sku, 4096, 80*1024, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want, err := calculateAutoGpuMemoryUtilization(4096, 80*1024, 1)
if err != nil {
t.Fatalf("reference calc error: %v", err)
}
if got != want {
t.Fatalf("exclusive util = %v, want %v", got, want)
}
if got >= autoGpuMemoryUtilizationMax {
t.Fatalf("exclusive util on large GPU should be below max, got %v", got)
}
}
@@ -28,10 +28,6 @@ func TestSkuCanAutoGpuMemoryUtilizationLocalPath(t *testing.T) {
if skuCanAutoGpuMemoryUtilization(localSku) {
t.Fatal("local_path SKU should not allow auto GPU util")
}
localSku.VramClaimMb = 16384
if skuCanAutoGpuMemoryUtilization(localSku) {
t.Fatal("local_path SKU should not allow auto GPU util even with vram_claim_mb")
}
if !skuCanAutoGpuMemoryUtilization(nil) {
t.Fatal("nil sku should keep legacy default true")
}
@@ -58,7 +54,6 @@ func TestDisableDeploymentAutoGpuMemoryUtilizationForLocalPath(t *testing.T) {
func TestMaxMountedModelVramRequirementMBLocalPathRejected(t *testing.T) {
sku := localPathTestSku()
sku.VramClaimMb = 20480
_, err := maxMountedModelVramRequirementMB(sku)
if err == nil {
t.Fatal("expected error for local_path SKU auto GPU util")
+98
View File
@@ -0,0 +1,98 @@
package models
import (
"fmt"
"path"
"sort"
"strings"
"yunion.io/x/log"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
)
// CollectContainerModelMountPaths returns container-absolute model directories from
// InstantModel.Mounts (effective llm/sku mounted models) and local_path HostPaths.
func CollectContainerModelMountPaths(llm *SLLM, sku *SLLMSku) []string {
seen := make(map[string]struct{})
var out []string
add := func(p string) {
p = strings.TrimSpace(p)
if p == "" {
return
}
if _, ok := seen[p]; ok {
return
}
seen[p] = struct{}{}
out = append(out, p)
}
ids := GetEffectiveMountedModels(llm, sku)
if len(ids) > 0 {
instModels := make(map[string]SInstantModel)
if err := db.FetchModelObjectsByIds(GetInstantModelManager(), "id", ids, &instModels); err != nil {
log.Errorf("CollectContainerModelMountPaths FetchModelObjectsByIds: %v", err)
} else {
for _, id := range ids {
inst, ok := instModels[id]
if !ok {
continue
}
for _, m := range inst.Mounts {
add(m)
}
}
}
}
for _, p := range collectLocalHostPathMountPaths(sku) {
add(p)
}
sort.Strings(out)
return out
}
func collectLocalHostPathMountPaths(sku *SLLMSku) []string {
if !SkuHasLocalHostPathModel(sku) {
return nil
}
key := fmt.Sprintf("%d", 0)
var out []string
for _, hp := range *sku.HostPaths {
if hp.IsZero() || hp.Containers == nil {
continue
}
rel, ok := hp.Containers[key]
if !ok || rel == nil {
continue
}
if p := strings.TrimSpace(rel.MountPath); p != "" {
out = append(out, p)
}
}
return out
}
// PickContainerModelMountPath selects a model directory from candidates.
// preferred may be an absolute path or a basename (e.g. PreferredModel).
func PickContainerModelMountPath(paths []string, preferred string) string {
if len(paths) == 0 {
return ""
}
preferred = strings.TrimSpace(preferred)
if preferred != "" {
for _, p := range paths {
if p == preferred {
return p
}
}
for _, p := range paths {
if path.Base(p) == preferred {
return p
}
}
}
return paths[0]
}
@@ -0,0 +1,51 @@
package models
import (
"testing"
api "yunion.io/x/onecloud/pkg/apis/llm"
)
func TestPickContainerModelMountPath(t *testing.T) {
paths := []string{
"/data/models/Other",
"/data/models/huggingface/Qwen3-0.6B",
}
got := PickContainerModelMountPath(paths, "Qwen3-0.6B")
if got != "/data/models/huggingface/Qwen3-0.6B" {
t.Fatalf("basename preferred: got %q", got)
}
got = PickContainerModelMountPath(paths, "/data/models/Other")
if got != "/data/models/Other" {
t.Fatalf("exact preferred: got %q", got)
}
got = PickContainerModelMountPath(paths, "")
if got != "/data/models/Other" {
t.Fatalf("empty preferred first sorted: got %q", got)
}
if PickContainerModelMountPath(nil, "x") != "" {
t.Fatal("empty paths should return empty")
}
}
func TestCollectContainerModelMountPathsLocalHostPath(t *testing.T) {
hostPaths := api.HostPaths{
{
Type: "directory",
Path: "/data/models/Qwen3-8B",
Containers: api.ContainerHostPathRelations{
"0": {MountPath: "/data/models/huggingface/Qwen3-8B"},
},
},
}
sku := &SLLMSku{
LLMType: string(api.LLM_CONTAINER_VLLM),
Source: api.LLM_MODEL_SOURCE_LOCAL_PATH,
LocalPath: "/data/models/Qwen3-8B",
}
sku.HostPaths = &hostPaths
got := CollectContainerModelMountPaths(nil, sku)
if len(got) != 1 || got[0] != "/data/models/huggingface/Qwen3-8B" {
t.Fatalf("local_path mounts: got %#v", got)
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func GetLLMPodCreateInput(
llmImage *SLLMImage,
eip string,
) (*computeapi.ServerCreateInput, error) {
data, err := GetLLMBasePodCreateInput(ctx, userCred, &input.LLMBaseCreateInput, &llm.SLLMBase, &sku.SLLMSkuBase, eip)
data, err := GetLLMBasePodCreateInput(ctx, userCred, &input.LLMBaseCreateInput, &llm.SLLMBase, &sku.SLLMSkuBase, sku.EstimateVramClaimMb(), eip)
if err != nil {
return nil, errors.Wrap(err, "GetLLMBasePodCreateInput: ")
}
+93
View File
@@ -15,6 +15,7 @@ import (
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/llm/utils/vram"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/mcclient/auth"
imagemodules "yunion.io/x/onecloud/pkg/mcclient/modules/image"
@@ -170,6 +171,7 @@ func (manager *SLLMSkuManager) FetchCustomizeColumns(
} else {
for i, sku := range skus {
modelIds := sku.GetMountedModels()
res[i].VramClaimMb = EstimateVramClaimMbFromInstantModels(sku.LLMType, modelIds, instModels, effectiveMaxModelLen(&sku))
if len(modelIds) > 0 {
res[i].MountedModelDetails = make([]api.MountedModelInfo, 0)
for _, modelId := range modelIds {
@@ -185,6 +187,10 @@ func (manager *SLLMSkuManager) FetchCustomizeColumns(
}
}
}
} else {
for i := range skus {
res[i].VramClaimMb = 0
}
}
{
images := make(map[string]SLLMImage)
@@ -323,6 +329,93 @@ func (sku *SLLMSku) GetMountedModels() []string {
return drv.GetMountedModels(sku)
}
// EstimateVramClaimMb returns heuristic VRAM (MiB) from the largest mounted
// InstantModel's weight_size_bytes, including KV reserve for effective
// max_model_len. 0 means unknown / no mounted weights.
func (sku *SLLMSku) EstimateVramClaimMb() int {
if sku == nil {
return 0
}
return EstimateVramClaimMbFromMountedModels(sku.LLMType, sku.GetMountedModels(), effectiveMaxModelLen(sku))
}
// EstimateModelVramMb returns weight+framework VRAM (MiB) without KV reserve.
// Used as the numerator for auto gpu-memory-utilization; HAMI slice sizing
// continues to use EstimateVramClaimMb (WithContext).
func (sku *SLLMSku) EstimateModelVramMb() int {
if sku == nil {
return 0
}
return EstimateModelVramMbFromMountedModels(sku.LLMType, sku.GetMountedModels())
}
// effectiveMaxModelLen returns SKU-configured max-model-len, or the platform default.
func effectiveMaxModelLen(sku *SLLMSku) int {
if sku == nil {
return api.LLM_DEFAULT_CONTEXT_TOKENS
}
keys := []string{"max-model-len"}
if val, ok := tokenLimitFromBackendParameters(sku.BackendParameters, keys); ok && val > 0 {
return int(val)
}
switch api.LLMContainerType(sku.LLMType) {
case api.LLM_CONTAINER_VLLM:
if sku.LLMSpec != nil && sku.LLMSpec.Vllm != nil {
for _, arg := range sku.LLMSpec.Vllm.CustomizedArgs {
if arg == nil || !runtimeArgKeyIn(arg.Key, keys) {
continue
}
if val, ok := parseTokenLimitValue(arg.Value); ok && val > 0 {
return int(val)
}
}
}
case api.LLM_CONTAINER_SGLANG:
if val, ok := sglangRuntimeIntArg(sku, keys); ok && val > 0 {
return int(val)
}
}
return api.LLM_DEFAULT_CONTEXT_TOKENS
}
func maxMountedInstantModelWeightBytes(modelIds []string) int64 {
var maxWeight int64
for _, id := range modelIds {
if strings.TrimSpace(id) == "" {
continue
}
obj, err := GetInstantModelManager().FetchById(id)
if err != nil {
continue
}
if w := obj.(*SInstantModel).WeightSizeBytes; w > maxWeight {
maxWeight = w
}
}
return maxWeight
}
// EstimateModelVramMbFromMountedModels estimates model VRAM without KV reserve.
func EstimateModelVramMbFromMountedModels(llmType string, modelIds []string) int {
return vram.EstimateClaimMb(maxMountedInstantModelWeightBytes(modelIds), llmType)
}
// EstimateVramClaimMbFromMountedModels estimates VRAM from InstantModel ids.
func EstimateVramClaimMbFromMountedModels(llmType string, modelIds []string, maxModelLen int) int {
return vram.EstimateClaimMbWithContext(maxMountedInstantModelWeightBytes(modelIds), llmType, maxModelLen)
}
// EstimateVramClaimMbFromInstantModels uses already-fetched InstantModel rows.
func EstimateVramClaimMbFromInstantModels(llmType string, modelIds []string, models map[string]SInstantModel, maxModelLen int) int {
var maxWeight int64
for _, id := range modelIds {
if m, ok := models[id]; ok && m.WeightSizeBytes > maxWeight {
maxWeight = m.WeightSizeBytes
}
}
return vram.EstimateClaimMbWithContext(maxWeight, llmType, maxModelLen)
}
func (sku *SLLMSku) GetLLMContainerDriver() ILLMContainerDriver {
return GetLLMContainerDriver(api.LLMContainerType(sku.LLMType))
}
+4
View File
@@ -6,6 +6,7 @@ import (
"yunion.io/x/pkg/errors"
imageapi "yunion.io/x/onecloud/pkg/apis/image"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/httperrors"
@@ -122,6 +123,9 @@ func EnableInstantModelForUse(ctx context.Context, userCred mcclient.TokenCreden
return errors.Wrapf(err, "fetch InstantModel %s", id)
}
im := obj.(*SInstantModel)
if im.Status != imageapi.IMAGE_STATUS_ACTIVE {
return errors.Wrapf(errors.ErrInvalidStatus, "cannot enable InstantModel %s of status %s", id, im.Status)
}
if im.Enabled.IsTrue() {
return nil
}
+53 -18
View File
@@ -33,14 +33,9 @@ type SLLMSkuBaseManager struct {
type SLLMSkuBase struct {
db.SSharableVirtualResourceBase
Bandwidth int `nullable:"false" default:"0" create:"optional" list:"user" update:"user"`
Cpu int `nullable:"false" default:"1" create:"optional" list:"user" update:"user"`
Memory int `nullable:"false" default:"512" create:"optional" list:"user" update:"user"`
// VramClaimMb is the heuristic VRAM (MiB) needed to start a single SLLM
// instance from this SKU. Auto-filled from the largest mounted InstantModel's
// WeightSizeBytes via EstimateVramClaimMb; user can override at create/update
// time (any explicit non-zero value bypasses the auto-fill). 0 means unknown.
VramClaimMb int `nullable:"false" default:"0" create:"optional" list:"user" update:"user"`
Bandwidth int `nullable:"false" default:"0" create:"optional" list:"user" update:"user"`
Cpu int `nullable:"false" default:"1" create:"optional" list:"user" update:"user"`
Memory int `nullable:"false" default:"512" create:"optional" list:"user" update:"user"`
Volumes *api.Volumes `charset:"utf8" length:"medium" nullable:"true" list:"user" update:"user" create:"optional"`
HostPaths *api.HostPaths `charset:"utf8" length:"medium" nullable:"true" list:"user" update:"user" create:"optional"`
PortMappings *api.PortMappings `charset:"utf8" length:"medium" nullable:"true" list:"user" update:"user" create:"optional"`
@@ -80,22 +75,56 @@ func (man *SLLMSkuBaseManager) ValidateCreateData(ctx context.Context, userCred
return input, errors.Wrap(httperrors.ErrInputParameter, "volumes cannot be empty")
}
// Default DevType to NVIDIA_GPU when callers omit it (UI's "auto by VRAM"
// path posts {} for each device). Without this the scheduler's
// (DevType, MemoryMb) aggregation key is empty and the VRAM filter
// silently no-ops.
if input.Devices != nil {
for i := range *input.Devices {
if (*input.Devices)[i].DevType == "" {
(*input.Devices)[i].DevType = computeapi.GPU_TYPE
}
}
if err := normalizeLLMSkuDevices(input.Devices); err != nil {
return input, err
}
input.Status = api.STATUS_READY
return input, nil
}
// normalizeLLMSkuDevices maps legacy NVIDIA_* DevTypes onto GPU + SharingMode,
// and defaults empty DevType/SharingMode to GPU + HAMI.
func normalizeLLMSkuDevices(devices *api.Devices) error {
if devices == nil || len(*devices) == 0 {
return nil
}
for i := range *devices {
normalizeLLMSkuDevice(&(*devices)[i])
}
return nil
}
func normalizeLLMSkuDevice(dev *api.Device) {
switch dev.DevType {
case "":
dev.DevType = computeapi.GPU_TYPE
case computeapi.CONTAINER_DEV_NVIDIA_GPU:
dev.DevType = computeapi.GPU_TYPE
if dev.SharingMode == "" {
dev.SharingMode = computeapi.DEVICE_SHARING_MODE_EXCLUSIVE
}
case computeapi.CONTAINER_DEV_NVIDIA_MPS:
dev.DevType = computeapi.GPU_TYPE
if dev.SharingMode == "" {
dev.SharingMode = computeapi.DEVICE_SHARING_MODE_MPS
}
case computeapi.CONTAINER_DEV_NVIDIA_GPU_SHARE:
dev.DevType = computeapi.GPU_TYPE
if dev.SharingMode == "" {
dev.SharingMode = computeapi.DEVICE_SHARING_MODE_UNLIMITED
}
case computeapi.CONTAINER_DEV_NVIDIA_HAMI:
dev.DevType = computeapi.GPU_TYPE
if dev.SharingMode == "" {
dev.SharingMode = computeapi.DEVICE_SHARING_MODE_HAMI
}
}
if dev.SharingMode == "" {
dev.SharingMode = computeapi.DEVICE_SHARING_MODE_HAMI
}
}
func (skuBase *SLLMSkuBase) ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.LLMSkuBaseUpdateInput) (api.LLMSkuBaseUpdateInput, error) {
var err error
input.SharableVirtualResourceBaseUpdateInput, err = skuBase.SSharableVirtualResourceBase.ValidateUpdateData(ctx, userCred, query, input.SharableVirtualResourceBaseUpdateInput)
@@ -130,5 +159,11 @@ func (skuBase *SLLMSkuBase) ValidateUpdateData(ctx context.Context, userCred mcc
}
input.Volumes = (*api.Volumes)(&volumes)
if input.Devices != nil {
if err := normalizeLLMSkuDevices(input.Devices); err != nil {
return input, err
}
}
return input, nil
}
+305
View File
@@ -0,0 +1,305 @@
package models
import (
"testing"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/llm/utils/vram"
)
func TestNormalizeLLMSkuDeviceDefaults(t *testing.T) {
dev := api.Device{}
normalizeLLMSkuDevice(&dev)
if dev.DevType != computeapi.GPU_TYPE {
t.Fatalf("DevType = %q, want %q", dev.DevType, computeapi.GPU_TYPE)
}
if dev.SharingMode != computeapi.DEVICE_SHARING_MODE_HAMI {
t.Fatalf("SharingMode = %q, want %q", dev.SharingMode, computeapi.DEVICE_SHARING_MODE_HAMI)
}
}
func TestNormalizeLLMSkuDeviceLegacyTypes(t *testing.T) {
cases := []struct {
name string
in api.Device
wantDevType string
wantSharingMode string
}{
{
name: "NVIDIA_GPU_SHARE",
in: api.Device{DevType: computeapi.CONTAINER_DEV_NVIDIA_GPU_SHARE},
wantDevType: computeapi.GPU_TYPE,
wantSharingMode: computeapi.DEVICE_SHARING_MODE_UNLIMITED,
},
{
name: "NVIDIA_MPS",
in: api.Device{DevType: computeapi.CONTAINER_DEV_NVIDIA_MPS},
wantDevType: computeapi.GPU_TYPE,
wantSharingMode: computeapi.DEVICE_SHARING_MODE_MPS,
},
{
name: "NVIDIA_GPU",
in: api.Device{DevType: computeapi.CONTAINER_DEV_NVIDIA_GPU},
wantDevType: computeapi.GPU_TYPE,
wantSharingMode: computeapi.DEVICE_SHARING_MODE_EXCLUSIVE,
},
{
name: "NVIDIA_HAMI",
in: api.Device{DevType: computeapi.CONTAINER_DEV_NVIDIA_HAMI},
wantDevType: computeapi.GPU_TYPE,
wantSharingMode: computeapi.DEVICE_SHARING_MODE_HAMI,
},
{
name: "explicit sharing_mode preserved",
in: api.Device{DevType: computeapi.CONTAINER_DEV_NVIDIA_GPU_SHARE, SharingMode: computeapi.DEVICE_SHARING_MODE_MPS},
wantDevType: computeapi.GPU_TYPE,
wantSharingMode: computeapi.DEVICE_SHARING_MODE_MPS,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dev := tc.in
normalizeLLMSkuDevice(&dev)
if dev.DevType != tc.wantDevType {
t.Fatalf("DevType = %q, want %q", dev.DevType, tc.wantDevType)
}
if dev.SharingMode != tc.wantSharingMode {
t.Fatalf("SharingMode = %q, want %q", dev.SharingMode, tc.wantSharingMode)
}
})
}
}
func TestNormalizeLLMSkuDevicesAllowsHAMIWithoutStoredVram(t *testing.T) {
devs := api.Devices{{Model: "A100"}}
if err := normalizeLLMSkuDevices(&devs); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if devs[0].SharingMode != computeapi.DEVICE_SHARING_MODE_HAMI {
t.Fatalf("SharingMode = %q", devs[0].SharingMode)
}
}
func TestEffectiveMaxModelLen(t *testing.T) {
if got := effectiveMaxModelLen(nil); got != api.LLM_DEFAULT_CONTEXT_TOKENS {
t.Fatalf("nil sku → %d", got)
}
sku := &SLLMSku{LLMType: string(api.LLM_CONTAINER_VLLM)}
if got := effectiveMaxModelLen(sku); got != api.LLM_DEFAULT_CONTEXT_TOKENS {
t.Fatalf("default → %d", got)
}
sku.BackendParameters = []string{"--max-model-len=4096"}
if got := effectiveMaxModelLen(sku); got != 4096 {
t.Fatalf("backend param → %d, want 4096", got)
}
}
func TestEstimateVramClaimMbFromInstantModels(t *testing.T) {
models := map[string]SInstantModel{
"a": {WeightSizeBytes: 1024 * 1024 * 100}, // 100 MiB weights
"b": {WeightSizeBytes: 1024 * 1024 * 1000}, // 1000 MiB weights
}
maxLen := api.LLM_DEFAULT_CONTEXT_TOKENS
got := EstimateVramClaimMbFromInstantModels(string(api.LLM_CONTAINER_VLLM), []string{"a", "b"}, models, maxLen)
want := vram.EstimateClaimMbWithContext(1024*1024*1000, string(api.LLM_CONTAINER_VLLM), maxLen)
if got != want {
t.Fatalf("got %d want %d", got, want)
}
if EstimateVramClaimMbFromInstantModels(string(api.LLM_CONTAINER_VLLM), nil, models, maxLen) != 0 {
t.Fatal("empty model ids should yield 0")
}
base := vram.EstimateClaimMb(1024*1024*1000, string(api.LLM_CONTAINER_VLLM))
if want <= base {
t.Fatalf("claim WithContext (%d) should exceed model-only EstimateClaimMb (%d)", want, base)
}
}
func TestGetLLMBasePodCreateInputHAMIFields(t *testing.T) {
devs := api.Devices{{Model: "A100"}}
sku := &SLLMSkuBase{
Cpu: 4,
Memory: 8192,
Volumes: &api.Volumes{{SizeMB: 10240}},
Devices: &devs,
}
llm := &SLLMBase{}
input := &api.LLMBaseCreateInput{}
input.Name = "llm-hami-test"
input.AutoStart = true
input.ProjectId = "proj-test"
input.Nets = []*computeapi.NetworkConfig{{Network: "net1"}}
out, err := GetLLMBasePodCreateInput(nil, nil, input, llm, sku, 20480, "")
if err != nil {
t.Fatalf("GetLLMBasePodCreateInput: %v", err)
}
if len(out.IsolatedDevices) != 1 {
t.Fatalf("IsolatedDevices len = %d", len(out.IsolatedDevices))
}
dev := out.IsolatedDevices[0]
if dev.DevType != computeapi.GPU_TYPE {
t.Fatalf("DevType = %q", dev.DevType)
}
if dev.SharingMode != computeapi.DEVICE_SHARING_MODE_HAMI {
t.Fatalf("SharingMode = %q", dev.SharingMode)
}
if dev.MemoryMb != 20480 || dev.MemoryRequest != 20480 {
t.Fatalf("MemoryMb=%d MemoryRequest=%d", dev.MemoryMb, dev.MemoryRequest)
}
}
func TestGetLLMBasePodCreateInputHAMIRequiresClaim(t *testing.T) {
devs := api.Devices{{Model: "A100"}}
sku := &SLLMSkuBase{
Cpu: 4,
Memory: 8192,
Volumes: &api.Volumes{{SizeMB: 10240}},
Devices: &devs,
}
llm := &SLLMBase{}
input := &api.LLMBaseCreateInput{}
input.Name = "llm-hami-test"
input.AutoStart = true
input.ProjectId = "proj-test"
input.Nets = []*computeapi.NetworkConfig{{Network: "net1"}}
if _, err := GetLLMBasePodCreateInput(nil, nil, input, llm, sku, 0, ""); err == nil {
t.Fatal("expected error when HAMI with zero vram claim")
}
}
func TestGetLLMBasePodCreateInputHAMIManualMemoryMb(t *testing.T) {
devs := api.Devices{{Model: "A100", MemoryMb: 8192}}
sku := &SLLMSkuBase{
Cpu: 4,
Memory: 8192,
Volumes: &api.Volumes{{SizeMB: 10240}},
Devices: &devs,
}
llm := &SLLMBase{}
input := &api.LLMBaseCreateInput{}
input.Name = "llm-hami-manual-mem"
input.AutoStart = true
input.ProjectId = "proj-test"
input.Nets = []*computeapi.NetworkConfig{{Network: "net1"}}
// Manual memory_mb allows claim=0.
out, err := GetLLMBasePodCreateInput(nil, nil, input, llm, sku, 0, "")
if err != nil {
t.Fatalf("GetLLMBasePodCreateInput: %v", err)
}
if len(out.IsolatedDevices) != 1 {
t.Fatalf("IsolatedDevices len = %d", len(out.IsolatedDevices))
}
dev := out.IsolatedDevices[0]
if dev.MemoryMb != 8192 || dev.MemoryRequest != 8192 {
t.Fatalf("MemoryMb=%d MemoryRequest=%d want 8192", dev.MemoryMb, dev.MemoryRequest)
}
// Manual memory_mb wins over claim split.
out2, err := GetLLMBasePodCreateInput(nil, nil, input, llm, sku, 40960, "")
if err != nil {
t.Fatalf("GetLLMBasePodCreateInput with claim: %v", err)
}
if out2.IsolatedDevices[0].MemoryRequest != 8192 {
t.Fatalf("manual memory should win over claim, got %d", out2.IsolatedDevices[0].MemoryRequest)
}
}
func TestGetLLMBasePodCreateInputHAMIMixedMemoryFallback(t *testing.T) {
devs := api.Devices{
{Model: "A100", MemoryMb: 10240},
{Model: "A100"},
}
sku := &SLLMSkuBase{
Cpu: 4,
Memory: 8192,
Volumes: &api.Volumes{{SizeMB: 10240}},
Devices: &devs,
}
llm := &SLLMBase{}
input := &api.LLMBaseCreateInput{}
input.Name = "llm-hami-mixed"
input.AutoStart = true
input.ProjectId = "proj-test"
input.Nets = []*computeapi.NetworkConfig{{Network: "net1"}}
claim := 40960
perDevFromClaim := (claim + len(devs) - 1) / len(devs)
out, err := GetLLMBasePodCreateInput(nil, nil, input, llm, sku, claim, "")
if err != nil {
t.Fatalf("GetLLMBasePodCreateInput: %v", err)
}
if out.IsolatedDevices[0].MemoryRequest != 10240 {
t.Fatalf("device0 MemoryRequest = %d want 10240", out.IsolatedDevices[0].MemoryRequest)
}
if out.IsolatedDevices[1].MemoryRequest != perDevFromClaim {
t.Fatalf("device1 MemoryRequest = %d want %d", out.IsolatedDevices[1].MemoryRequest, perDevFromClaim)
}
}
func TestLLMPodIsolatedDeviceConfigFromSKU(t *testing.T) {
devs := api.Devices{
{Model: "A100"},
{DevType: computeapi.CONTAINER_DEV_NVIDIA_GPU_SHARE, Model: "A100"},
}
vramClaimMb := 40960
perDev := (vramClaimMb + len(devs) - 1) / len(devs)
out := make([]*computeapi.IsolatedDeviceConfig, 0, len(devs))
for i := range devs {
normalizeLLMSkuDevice(&devs[i])
out = append(out, &computeapi.IsolatedDeviceConfig{
DevType: devs[i].DevType,
SharingMode: devs[i].SharingMode,
Model: devs[i].Model,
DevicePath: devs[i].DevicePath,
MemoryMb: perDev,
MemoryRequest: perDev,
SmUtilLimit: devs[i].SmUtilLimit,
})
}
if out[0].DevType != computeapi.GPU_TYPE || out[0].SharingMode != computeapi.DEVICE_SHARING_MODE_HAMI {
t.Fatalf("device0 = %#v", out[0])
}
if out[1].DevType != computeapi.GPU_TYPE || out[1].SharingMode != computeapi.DEVICE_SHARING_MODE_UNLIMITED {
t.Fatalf("device1 = %#v", out[1])
}
if out[0].MemoryRequest != perDev || out[1].MemoryRequest != perDev {
t.Fatalf("MemoryRequest = %d,%d want %d", out[0].MemoryRequest, out[1].MemoryRequest, perDev)
}
}
func TestBuildIsolatedDeviceMemoryParamsDefaultsHAMI(t *testing.T) {
params := buildIsolatedDeviceMemoryParams(api.Device{Model: "A100"})
if params.Contains("unused") {
t.Fatal("HAMI params should not set unused")
}
devTypes, err := params.GetArray("dev_type")
if err != nil || len(devTypes) != 1 {
t.Fatalf("dev_type = %v err=%v", params, err)
}
if s, _ := devTypes[0].GetString(); s != computeapi.GPU_TYPE {
t.Fatalf("dev_type = %q", s)
}
modes, err := params.GetArray("sharing_mode")
if err != nil || len(modes) != 1 {
t.Fatalf("sharing_mode = %v err=%v", params, err)
}
if s, _ := modes[0].GetString(); s != computeapi.DEVICE_SHARING_MODE_HAMI {
t.Fatalf("sharing_mode = %q", s)
}
}
func TestBuildIsolatedDeviceMemoryParamsExclusiveUsesUnused(t *testing.T) {
params := buildIsolatedDeviceMemoryParams(api.Device{
DevType: computeapi.GPU_TYPE,
SharingMode: computeapi.DEVICE_SHARING_MODE_EXCLUSIVE,
Model: "A100",
})
if !params.Contains("unused") {
t.Fatal("exclusive params should set unused")
}
}
+4 -1
View File
@@ -336,7 +336,10 @@ func handleLLMRouterAgentRoute(ctx context.Context, w http.ResponseWriter, r *ht
httperrors.GeneralServerError(ctx, w, err)
return
}
appsrv.SendStruct(w, out)
// Wrap with keyword so mcclient/apigateway PerformAction can parse the response.
wrapped := jsonutils.NewDict()
wrapped.Set(models.GetLLMRouterAgentManager().Keyword(), jsonutils.Marshal(out))
appsrv.SendJSON(w, wrapped)
}
func InitHandlers(app *appsrv.Application, isSlave bool) {
@@ -15,7 +15,6 @@ import (
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
"yunion.io/x/onecloud/pkg/llm/models"
"yunion.io/x/onecloud/pkg/llm/utils/vram"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/util/logclient"
)
@@ -68,11 +67,12 @@ func (task *LLMDeploymentCreateTask) OnInit(ctx context.Context, obj db.IStandal
case input.ModelSpec != nil:
// Mode C: optionally import InstantModel first, then create SKU.
// If an enabled InstantModel with the same (llm_type, model_name,
// model_tag) already exists, skip the import and reuse it.
// model_tag, source) already exists, skip the import and reuse it.
if existing, err := models.GetInstantModelManager().FindReadyInstantModel(
string(input.ModelSpec.LlmType),
input.ModelSpec.ModelName,
input.ModelSpec.ModelTag,
input.ModelSpec.Source,
); err != nil {
log.Warningf("FindReadyInstantModel: %s — proceeding with fresh import", err)
} else if existing != nil {
@@ -222,26 +222,6 @@ func (task *LLMDeploymentCreateTask) createSkuAndReconcile(ctx context.Context,
skuSpec.Name = fmt.Sprintf("%s-sku", model.Name)
}
// Auto-fill VramClaimMb from the largest mounted InstantModel's
// weight_size_bytes. User-provided non-zero values are respected.
if skuSpec.VramClaimMb == 0 {
var maxWeight int64
for _, id := range skuSpec.MountedModels {
obj, err := models.GetInstantModelManager().FetchById(id)
if err != nil {
continue
}
if w := obj.(*models.SInstantModel).WeightSizeBytes; w > maxWeight {
maxWeight = w
}
}
if maxWeight > 0 {
skuSpec.VramClaimMb = vram.EstimateClaimMb(maxWeight, skuSpec.LLMType)
log.Infof("LLMDeploymentCreateTask: auto vram_claim_mb=%d for sku=%s (weight=%d bytes, llm_type=%s)",
skuSpec.VramClaimMb, skuSpec.Name, maxWeight, skuSpec.LLMType)
}
}
skuParams := jsonutils.Marshal(skuSpec).(*jsonutils.JSONDict)
// __meta__ from VirtualResourceCreateInput marshals as null when Metadata is nil;
// keep params clean to avoid surprises in the framework dispatcher.
+1
View File
@@ -45,6 +45,7 @@ func (task *LLMSkuCreateTask) OnInit(ctx context.Context, obj db.IStandaloneMode
string(importInput.LlmType),
importInput.ModelName,
importInput.ModelTag,
importInput.Source,
); err != nil {
log.Warningf("LLMSkuCreateTask FindReadyInstantModel: %s; importing a fresh InstantModel", err)
} else if existing != nil {
+24
View File
@@ -20,6 +20,10 @@ import api "yunion.io/x/onecloud/pkg/apis/llm"
// 3B → ~8.9 GiB
// 7B → ~19.0 GiB
// 72B → ~164.5 GiB
//
// EstimateClaimMbWithContext additionally reserves KV headroom for
// max_model_len (≈ 256 KiB/token → len/4 MiB) so HAMI slices can satisfy
// vLLM's KV check at the configured context length.
const (
activationOverheadFactor = 1.2
llmFrameworkOverheadMB = 2048 // 2 GiB
@@ -47,6 +51,26 @@ func EstimateClaimMb(weightSizeBytes int64, llmType string) int {
return int(float64(weightMb)*activationOverheadFactor) + overhead
}
// EstimateKvCacheReserveMb returns a conservative KV-cache headroom in MiB
// for the given max_model_len. Uses ~256 KiB/token (len/4 MiB). When
// maxModelLen <= 0, defaults to api.LLM_DEFAULT_CONTEXT_TOKENS.
func EstimateKvCacheReserveMb(maxModelLen int) int {
if maxModelLen <= 0 {
maxModelLen = api.LLM_DEFAULT_CONTEXT_TOKENS
}
return maxModelLen / 4
}
// EstimateClaimMbWithContext is EstimateClaimMb plus KV reserve for LLM
// backends. Non-LLM / image types ignore maxModelLen (same as EstimateClaimMb).
func EstimateClaimMbWithContext(weightSizeBytes int64, llmType string, maxModelLen int) int {
base := EstimateClaimMb(weightSizeBytes, llmType)
if base <= 0 || !isLLMType(llmType) {
return base
}
return base + EstimateKvCacheReserveMb(maxModelLen)
}
// isLLMType reports whether the backend serves text-generation LLMs that get
// the larger framework overhead (CUDA graphs, runtime buffers, KV scratch).
func isLLMType(t string) bool {
+31
View File
@@ -81,3 +81,34 @@ func TestEstimateClaimMb(t *testing.T) {
})
}
}
func TestEstimateKvCacheReserveMb(t *testing.T) {
if got := EstimateKvCacheReserveMb(8192); got != 2048 {
t.Fatalf("8192 → %d, want 2048", got)
}
if got := EstimateKvCacheReserveMb(0); got != api.LLM_DEFAULT_CONTEXT_TOKENS/4 {
t.Fatalf("0 → %d, want default/4=%d", got, api.LLM_DEFAULT_CONTEXT_TOKENS/4)
}
if got := EstimateKvCacheReserveMb(4096); got != 1024 {
t.Fatalf("4096 → %d, want 1024", got)
}
}
func TestEstimateClaimMbWithContext(t *testing.T) {
weightBytes := int64(1024 * 1024 * 1000) // 1000 MiB
base := EstimateClaimMb(weightBytes, string(api.LLM_CONTAINER_VLLM))
got := EstimateClaimMbWithContext(weightBytes, string(api.LLM_CONTAINER_VLLM), 8192)
if got != base+2048 {
t.Fatalf("with context = %d, want base+2048=%d (base=%d)", got, base+2048, base)
}
// ComfyUI: no KV reserve
imgWeight := int64(2 * 1024 * 1024 * 1024)
imgBase := EstimateClaimMb(imgWeight, string(api.LLM_CONTAINER_COMFYUI))
imgGot := EstimateClaimMbWithContext(imgWeight, string(api.LLM_CONTAINER_COMFYUI), 8192)
if imgGot != imgBase {
t.Fatalf("comfyui should ignore context, got %d base %d", imgGot, imgBase)
}
if EstimateClaimMbWithContext(0, string(api.LLM_CONTAINER_VLLM), 8192) != 0 {
t.Fatal("zero weight should stay 0")
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ type LLMDeploymentCreateOptions struct {
SkuStorageType string `help:"SKU storage type, e.g. local" json:"-"`
SkuTemplateId string `help:"SKU storage template id" json:"-"`
SkuPortMappings []string `help:"port mapping protocol:port[:prefix][:offset][:envs]; repeatable" json:"-"`
SkuDevices []string `help:"device model[:path[:dev_type]]; repeatable" json:"-"`
SkuDevices []string `help:"device model[:path[:dev_type[:sharing_mode]]]; repeatable" json:"-"`
SkuEnv []string `help:"env key=value; repeatable" json:"-"`
SkuProperty []string `help:"property key=value; repeatable" json:"-"`
SkuMountedModels []string `help:"already-imported InstantModel ref, format name:tag; repeatable" json:"-"`
+10 -5
View File
@@ -24,7 +24,7 @@ type LLMSkuBaseCreateOptions struct {
// DiskOverlay string `help:"disk overlay, e.g. /opt/steam-data/base:/opt/steam-data/games"`
TemplateId string
PortMappings []string `help:"port mapping in the format of protocol:port[:prefix][:first_port_offset][:env_key=env_value], e.g. tcp:5555:192.168.0.0/16:5:WOLF_BASE_PORT=20000"`
Devices []string `help:"device info in the format of model[:path[:dev_type]], e.g. 'GeForce RTX 4060'"`
Devices []string `help:"device info in the format of model[:path[:dev_type[:sharing_mode]]], e.g. 'GeForce RTX 4060'"`
HostPaths []string `json:"-" help:"host path mount in format path=<host_path>,type=<directory|file>,container_index=<index>,mount_path=<container_path>[,auto_create=<bool>][,read_only=<bool>][,propagation=<private|rslave|rshared>][,fs_user=<uid>][,fs_group=<gid>][,uid=<uid>][,gid=<gid>][,permissions=<mode>]; repeatable"`
Env []string `help:"env in format of key=value"`
@@ -69,7 +69,7 @@ type LLMSkuBaseUpdateOptions struct {
// Dpi *int
// Fps *int
PortMappings []string `help:"port mapping in the format of protocol:port[:prefix][:first_port_offset], e.g. tcp:5555:192.168.0.0/16,10.10.0.0/16:1000"`
Devices []string `help:"device info in the format of model[:path[:dev_type]], e.g. QuadraT2A:/dev/nvme1n1, Device::VASTAITECH_GPU"`
Devices []string `help:"device info in the format of model[:path[:dev_type[:sharing_mode]]], e.g. QuadraT2A:/dev/nvme1n1, Device::VASTAITECH_GPU"`
HostPaths []string `json:"-" help:"host path mount in format path=<host_path>,type=<directory|file>,container_index=<index>,mount_path=<container_path>[,auto_create=<bool>][,read_only=<bool>][,propagation=<private|rslave|rshared>][,fs_user=<uid>][,fs_group=<gid>][,uid=<uid>][,gid=<gid>][,permissions=<mode>]; repeatable"`
Env []string `help:"env in the format of key=value, e.g. AUTHENTICATION_PATH=/bupt-test/"`
Property []string `help:"extra properties of key=value, e.g. tango32=true"`
@@ -364,16 +364,21 @@ func fetchDevices(devStrs []string, dict *jsonutils.JSONDict) {
if len(segs) > 0 {
devpath := ""
devType := ""
sharingMode := ""
if len(segs) > 1 {
devpath = segs[1]
}
if len(segs) > 2 {
devType = segs[2]
}
if len(segs) > 3 {
sharingMode = segs[3]
}
devs = append(devs, api.Device{
Model: segs[0],
DevicePath: devpath,
DevType: devType,
Model: segs[0],
DevicePath: devpath,
DevType: devType,
SharingMode: sharingMode,
})
}
}