mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
294 lines
11 KiB
Go
294 lines
11 KiB
Go
/*
|
|
Copyright 2026 The KServe Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package llmisvc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/utils/ptr"
|
|
"knative.dev/pkg/kmeta"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
"github.com/kserve/kserve/pkg/utils"
|
|
)
|
|
|
|
const (
|
|
// loraAdaptersMountRoot must not be under constants.DefaultModelLocalMountPath: the base model
|
|
// is mounted there read-only, and nested volume mounts require mkdir on the parent (fails on RO).
|
|
loraAdaptersMountRoot = "/mnt/lora"
|
|
// loraAdapterDocsURL is linked in user-facing error messages for better UX.
|
|
loraAdapterDocsURL = "https://github.com/kserve/kserve/blob/master/docs/samples/llmisvc/lora-adapters/README.md"
|
|
)
|
|
|
|
// loraPathInvalidCharsRe matches characters that are invalid in filesystem paths.
|
|
// Replaces anything that is not alphanumeric, dash, underscore, or dot.
|
|
var loraPathInvalidCharsRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
|
|
|
// loraVolumeNameInvalidCharsRe matches characters that are invalid in Kubernetes volume names
|
|
// (which must be DNS labels: lowercase alphanumeric and hyphens only).
|
|
var loraVolumeNameInvalidCharsRe = regexp.MustCompile(`[^a-z0-9-]`)
|
|
|
|
// resolvedLoRAAdapter is one adapter after URI validation (hf/s3 downloads are handled in attachModelArtifacts).
|
|
type resolvedLoRAAdapter struct {
|
|
name string
|
|
mountPath string
|
|
uri string
|
|
scheme string
|
|
}
|
|
|
|
// loraModuleJSON is the JSON format for vLLM's --lora-modules flag.
|
|
type loraModuleJSON struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// enumerateLoRAAdapters validates spec.model.lora.adapters and returns mount paths and schemes.
|
|
// Returns adapters sorted by name for stable pod spec generation.
|
|
func enumerateLoRAAdapters(spec v1alpha2.LLMInferenceServiceSpec) ([]resolvedLoRAAdapter, error) {
|
|
if spec.Model.LoRA == nil || len(spec.Model.LoRA.Adapters) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
storageInitializerDisabled := spec.StorageInitializer != nil &&
|
|
spec.StorageInitializer.Enabled != nil &&
|
|
!*spec.StorageInitializer.Enabled
|
|
|
|
// Sort by name for stable ordering: ensures the same spec always produces the same
|
|
// pod spec regardless of the order adapters appear in spec.model.lora.adapters.
|
|
adapters := slices.SortedFunc(slices.Values(spec.Model.LoRA.Adapters), func(a, b v1alpha2.LLMModelSpec) int {
|
|
return strings.Compare(ptr.Deref(a.Name, ""), ptr.Deref(b.Name, ""))
|
|
})
|
|
out := make([]resolvedLoRAAdapter, 0, len(adapters))
|
|
|
|
for _, adapter := range adapters {
|
|
adapterName := ptr.Deref(adapter.Name, "")
|
|
|
|
uri := adapter.URI.String()
|
|
schema, _, sepFound := strings.Cut(uri, "://")
|
|
if !sepFound {
|
|
return nil, fmt.Errorf("LoRA adapter %q: invalid URI %q", adapterName, uri)
|
|
}
|
|
scheme := schema + "://"
|
|
mountPath := filepath.Join(loraAdaptersMountRoot, sanitizeLoRAPathSegment(adapterName))
|
|
|
|
switch scheme {
|
|
case constants.HfURIPrefix, constants.S3URIPrefix:
|
|
if storageInitializerDisabled {
|
|
return nil, fmt.Errorf("LoRA adapter %q: hf:// and s3:// require the storage initializer — set storageInitializer.enabled to true (see %s)", adapterName, loraAdapterDocsURL)
|
|
}
|
|
case constants.PvcURIPrefix:
|
|
if storageInitializerDisabled {
|
|
return nil, fmt.Errorf("LoRA adapter %q: pvc:// requires a mounted volume — do not set storageInitializer.enabled to false (see %s)", adapterName, loraAdapterDocsURL)
|
|
}
|
|
case constants.OciURIPrefix:
|
|
// oci:// is intentionally not supported for LoRA adapters. OCI models run as sidecar
|
|
// containers ("modelcars") with shared process namespaces, but only one modelcar per pod
|
|
// is currently supported. Workaround: package the adapter in a PVC and use pvc://.
|
|
return nil, fmt.Errorf("LoRA adapter %q: oci:// is not supported for LoRA adapters; use hf://, s3://, or pvc:// instead (see %s)", adapterName, loraAdapterDocsURL)
|
|
default:
|
|
return nil, fmt.Errorf("LoRA adapter %q: unsupported URI scheme %q; supported schemes are hf://, s3://, pvc:// (see %s)", adapterName, scheme, loraAdapterDocsURL)
|
|
}
|
|
|
|
out = append(out, resolvedLoRAAdapter{
|
|
name: adapterName,
|
|
mountPath: mountPath,
|
|
uri: uri,
|
|
scheme: scheme,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// collectLoRADownloadPairs filters pre-resolved adapters to hf:// and s3:// uri/path pairs
|
|
// for a single storage-initializer run.
|
|
func collectLoRADownloadPairs(adapters []resolvedLoRAAdapter) []storageDownloadPair {
|
|
var pairs []storageDownloadPair
|
|
for _, a := range adapters {
|
|
if a.scheme == constants.HfURIPrefix || a.scheme == constants.S3URIPrefix {
|
|
pairs = append(pairs, storageDownloadPair{uri: a.uri, path: a.mountPath})
|
|
}
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
// attachLoRAAdapters reconciles spec.model.lora.adapters into vLLM CLI flags appended to the main
|
|
// container's Args. hf:// and s3:// adapters are downloaded in attachModelArtifacts via one
|
|
// storage-initializer; pvc:// adapters are mounted here.
|
|
func (r *LLMISVCReconciler) attachLoRAAdapters(
|
|
ctx context.Context,
|
|
llmSvc *v1alpha2.LLMInferenceService,
|
|
podSpec *corev1.PodSpec,
|
|
adapters []resolvedLoRAAdapter,
|
|
) error {
|
|
const containerName = "main"
|
|
|
|
if len(adapters) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var loraModules []string
|
|
for _, a := range adapters {
|
|
switch a.scheme {
|
|
case constants.PvcURIPrefix:
|
|
volName := kmeta.ChildName("lora-pvc-", a.name)
|
|
if err := attachLoraPVCAdapter(a.uri, podSpec, containerName, a.mountPath, volName); err != nil {
|
|
return fmt.Errorf("LoRA adapter %q: %w", a.name, err)
|
|
}
|
|
case constants.HfURIPrefix, constants.S3URIPrefix:
|
|
// Downloaded alongside the base model in attachModelArtifacts.
|
|
default:
|
|
return fmt.Errorf("LoRA adapter %q: internal error, unhandled scheme %q", a.name, a.scheme)
|
|
}
|
|
for _, mod := range []loraModuleJSON{
|
|
{Name: a.name, Path: a.mountPath},
|
|
{Name: fmt.Sprintf("publishers/%s/models/%s", llmSvc.Namespace, a.name), Path: a.mountPath},
|
|
} {
|
|
m, err := json.Marshal(mod)
|
|
if err != nil {
|
|
return fmt.Errorf("LoRA adapter %q: failed to marshal module JSON: %w", a.name, err)
|
|
}
|
|
loraModules = append(loraModules, string(m))
|
|
}
|
|
}
|
|
|
|
mainIdx := -1
|
|
for i := range podSpec.Containers {
|
|
if podSpec.Containers[i].Name == containerName {
|
|
mainIdx = i
|
|
break
|
|
}
|
|
}
|
|
if mainIdx < 0 {
|
|
return fmt.Errorf("no container %q in pod spec for LoRA injection", containerName)
|
|
}
|
|
main := &podSpec.Containers[mainIdx]
|
|
|
|
if hasValueFromLoRAConfig(main) {
|
|
log.FromContext(ctx).Info("VLLM_ADDITIONAL_ARGS is set via valueFrom; cannot inspect value at reconcile time — "+
|
|
"injecting --lora-modules as usual; if the referenced value already contains --lora-modules, duplicate flags may result",
|
|
"llmService", llmSvc.Name, "namespace", llmSvc.Namespace)
|
|
}
|
|
|
|
if userSuppliedLoRAConfig(main) {
|
|
log.FromContext(ctx).Info("Skipping controller LoRA injection: workload already sets --lora-modules",
|
|
"llmService", llmSvc.Name, "namespace", llmSvc.Namespace)
|
|
return nil
|
|
}
|
|
|
|
// vLLM sets its own defaults for these LoRA tuning parameters (rank=16, loras=1,
|
|
// cpu-loras=max_loras). We only inject them when the user explicitly sets the
|
|
// corresponding fields in the spec, letting vLLM apply its own defaults otherwise.
|
|
// See: https://github.com/vllm-project/vllm/blob/main/vllm/config/lora.py
|
|
loraSpec := llmSvc.Spec.Model.LoRA
|
|
|
|
var maxRank, maxAdapters, maxCpuAdapters *int32
|
|
if loraSpec != nil {
|
|
maxRank = loraSpec.MaxRank
|
|
maxAdapters = loraSpec.MaxAdapters
|
|
maxCpuAdapters = loraSpec.MaxCpuAdapters
|
|
}
|
|
|
|
addLoRAVLLMArgs(main, loraModules, maxRank, maxAdapters, maxCpuAdapters)
|
|
|
|
return nil
|
|
}
|
|
|
|
func userSuppliedLoRAConfig(c *corev1.Container) bool {
|
|
for _, e := range c.Env {
|
|
if e.Name == "VLLM_ADDITIONAL_ARGS" && strings.Contains(e.Value, "--lora-modules") {
|
|
return true
|
|
}
|
|
}
|
|
joined := strings.Join(c.Command, " ") + " " + strings.Join(c.Args, " ")
|
|
return strings.Contains(joined, "--lora-modules")
|
|
}
|
|
|
|
// hasValueFromLoRAConfig reports whether VLLM_ADDITIONAL_ARGS is set via valueFrom
|
|
// (ConfigMap/Secret reference), meaning the controller cannot inspect the value at reconcile time.
|
|
func hasValueFromLoRAConfig(c *corev1.Container) bool {
|
|
for _, e := range c.Env {
|
|
if e.Name == "VLLM_ADDITIONAL_ARGS" && e.ValueFrom != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addLoRAVLLMArgs appends vLLM LoRA flags to main.Args so the LLMInferenceServiceConfig
|
|
// entrypoint can pass them to `vllm serve` (eval "... $@") after the trailing `--` argv separator.
|
|
// Each loraModules entry is a JSON object (e.g. {"name":"...","path":"..."}); entries are
|
|
// single-quoted so they survive bash eval without brace expansion or quote stripping.
|
|
// loraMaxRank, loraMaxAdapters, and loraMaxCpuAdapters are only injected when non-nil (explicitly set in the spec);
|
|
// vLLM applies its own defaults otherwise.
|
|
// See: https://github.com/vllm-project/vllm/blob/main/vllm/config/lora.py
|
|
func addLoRAVLLMArgs(main *corev1.Container, loraModules []string, loraMaxRank, loraMaxAdapters, loraMaxCpuAdapters *int32) {
|
|
argv := make([]string, 0, 5+len(loraModules))
|
|
argv = append(argv, "--enable-lora")
|
|
if loraMaxRank != nil {
|
|
argv = append(argv, fmt.Sprintf("--max-lora-rank=%d", *loraMaxRank))
|
|
}
|
|
if loraMaxAdapters != nil {
|
|
argv = append(argv, fmt.Sprintf("--max-loras=%d", *loraMaxAdapters))
|
|
}
|
|
if loraMaxCpuAdapters != nil {
|
|
argv = append(argv, fmt.Sprintf("--max-cpu-loras=%d", *loraMaxCpuAdapters))
|
|
}
|
|
argv = append(argv, "--lora-modules")
|
|
// Single-quote each JSON object so it survives bash eval in the config template
|
|
// entrypoint (eval "vllm serve ... $@" would otherwise strip quotes and expand braces).
|
|
for _, m := range loraModules {
|
|
argv = append(argv, "'"+m+"'")
|
|
}
|
|
|
|
// Place injected args before so that user-provided arguments could override the injected arguments
|
|
main.Args = append(argv, main.Args...)
|
|
}
|
|
|
|
func sanitizeLoRAPathSegment(s string) string {
|
|
out := loraPathInvalidCharsRe.ReplaceAllString(s, "-")
|
|
// "." and ".." are valid after sanitization but dangerous as path components:
|
|
// filepath.Join("/mnt/lora", "..") resolves to "/mnt" (path traversal).
|
|
if out == "" || out == "." || out == ".." {
|
|
return "adapter"
|
|
}
|
|
return out
|
|
}
|
|
|
|
func attachLoraPVCAdapter(modelURI string, podSpec *corev1.PodSpec, workloadContainerName, mountPath, volumeName string) error {
|
|
pvcName, pvcPath, err := utils.ParsePvcURI(modelURI)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
storageMountParams := utils.StorageMountParams{
|
|
MountPath: mountPath,
|
|
VolumeName: volumeName,
|
|
ReadOnly: true,
|
|
PVCName: pvcName,
|
|
SubPath: pvcPath,
|
|
}
|
|
return utils.AddModelMount(storageMountParams, workloadContainerName, podSpec)
|
|
}
|