Skip to content

Commit 75be10a

Browse files
committed
fix(nvpair-engine-manager): handle FP8 models safely on macOS
Use the model dtype for a quantized KV cache when vLLM-Metal can load the checkpoint, while preserving explicit user launch options. Reject NVIDIA ModelOpt FP8 weight checkpoints before process startup with an actionable BF16, FP16, or MLX-compatible alternative because vLLM-Metal cannot load that tensor layout. Signed-off-by: nvrithik <rithikv@nvidia.com>
1 parent 6c1e452 commit 75be10a

7 files changed

Lines changed: 297 additions & 13 deletions

File tree

‎desktop/docs/services-parity.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,11 @@ rather than driver-aware or offline-repacked.
187187

188188
The bundled `vllm` manifest covers Linux (NVIDIA GPU) and Apple Silicon macOS
189189
(vLLM-Metal). It installs a pinned vLLM release into a private virtualenv
190-
through a checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed
191-
at a detected path. The engine's PAIR gateway listens on `8001`, with the
190+
at the space-free managed path `~/.nvpair/engine-bin/vllm` through a
191+
checksum-pinned `uv` bootstrap, or adopts a `vllm` already installed at a
192+
detected path. The space-free physical path lets FlashInfer and DeepGEMM compile
193+
load-time GPU kernels; reset and uninstall include the path while preserving the
194+
model cache. The engine's PAIR gateway listens on `8001`, with the
192195
`vllm serve` process on loopback `18001`, behind the proxy facade on `8000`. vLLM serves one model per process and has no
193196
load or unload API, so the engine manager puts a gateway on its port (`front`):
194197
Start and the restore after a restart bring it up idle with nothing loaded, the
@@ -213,7 +216,9 @@ restart, so the desktop marks the engine as switching: its card shows starting
213216
rather than stopped, and its model list is kept, until the load request finishes
214217
(success, or failure with the previous model restored). Weights live in the shared
215218
Hugging Face cache (`~/.cache/huggingface`), which uninstall and app-data reset
216-
never remove.
219+
never remove. On macOS the manager rejects NVIDIA ModelOpt FP8 weight
220+
checkpoints before launch because vLLM-Metal cannot load that tensor layout;
221+
the NVIDIA Linux path remains unchanged.
217222

218223
Current limits are explicit: the manual-node worker does not probe vLLM, the
219224
Inference Demo does not use it, and the icon is a placeholder lettermark rather

‎docs/engine-lifecycle.mdx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,16 @@ When you install an engine, consider the following:
9797
several GiB because it includes PyTorch and the CUDA libraries, and PAIR first
9898
checks for an NVIDIA driver that supports CUDA 13 or newer and about 25 GB of
9999
free disk, enough for the engine and its default model. On macOS it installs
100-
checksum-pinned vLLM and vLLM-Metal wheels and needs about 20 GB of free disk. Either way PAIR stops with a clear message if a
101-
requirement is missing.
102-
Nothing is compiled. vLLM serves a single model per process. Until you choose
100+
checksum-pinned vLLM and vLLM-Metal wheels and needs about 20 GB of free disk.
101+
Either way PAIR stops with a clear message if a requirement is missing.
102+
NVIDIA ModelOpt FP8 weight checkpoints run on the NVIDIA path but are not
103+
loadable by vLLM-Metal; on macOS PAIR rejects them before launch and asks for
104+
a BF16, FP16, or MLX-compatible quantized checkpoint.
105+
PAIR's private environment lives at
106+
`~/.nvpair/engine-bin/vllm`, outside the app-data path whose spaces break the
107+
load-time GPU builds used by some models. Reset and uninstall still remove it.
108+
Installation uses prebuilt wheels; loading a model may compile FlashInfer or
109+
DeepGEMM GPU kernels. vLLM serves a single model per process. Until you choose
103110
one it serves Qwen3.5-4B, downloaded on the first start (about 9.3 GB, and a GPU
104111
with at least 16 GB of memory); on a smaller GPU, add and load a smaller model
105112
first. Its model list

‎services/nvpair-engine-manager/README.md‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,34 @@ answers in plain text instead of failing with HTTP 400. The response carries
9797
`X-PAIR-Tool-Calls: removed: …`. A request with `tool_choice: "required"` or a
9898
named function is forwarded unchanged and gets vLLM's own error. A parser named
9999
in the launch arguments is always used as given.
100-
The environment sets `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's FlashInfer sampler
101-
compiles GPU kernels on first start, and that build cannot handle the spaces in
102-
PAIR's app-data folder (`Nvidia Corporation/Personal AI Router`), so the
103-
built-in sampler is used instead. As a side effect no CUDA toolkit or C++
104-
compiler is needed. The same limit means anything that forces a FlashInfer
105-
kernel build (for example choosing a FlashInfer attention backend) fails until
106-
the install can live on a path without spaces.
100+
PAIR installs its managed vLLM payload under `~/.nvpair/engine-bin/vllm`, rather
101+
than under the app-data folder whose `Nvidia Corporation/Personal AI Router`
102+
segments contain spaces. FlashInfer and DeepGEMM write unescaped source and
103+
include paths into load-time GPU builds, so the space-free physical location lets
104+
their attention and FP8/FP4 kernels compile normally. The engine's virtualenv is
105+
put first on `PATH` so those builds find its bundled `ninja`. Engine uninstall,
106+
Reset app data, and the platform uninstallers all remove this managed directory;
107+
downloaded Hugging Face models remain in `~/.cache/huggingface`.
108+
109+
The environment still sets `VLLM_USE_FLASHINFER_SAMPLER=0`, so an ordinary model
110+
does not need a CUDA toolkit solely for the optional FlashInfer sampler. Other
111+
FlashInfer and DeepGEMM kernels are not disabled, and vLLM chooses their attention
112+
backend normally.
113+
114+
Before choosing a context window and `--gpu-memory-utilization`, PAIR reads
115+
device memory from `nvidia-smi` when available and otherwise asks the installed
116+
vLLM platform. This covers CUDA/ROCm, Intel XPU, Apple Metal, CPU, and
117+
out-of-tree backends that expose vLLM's memory API. Shared-memory platforms keep
118+
3 GiB for runtime startup in addition to the normal 1 GiB free margin. If neither
119+
provider can measure free memory, PAIR does not invent a limit and leaves memory
120+
sizing to vLLM. On Apple Metal, a checkpoint whose weights are Metal-compatible
121+
but which declares an FP8 KV cache is launched with a dense KV cache matching its
122+
`bfloat16` or `float16` model dtype, because vLLM Metal stores paged KV data in
123+
the model dtype and rejects FP8 KV cache storage. An explicit launch option
124+
always wins. NVIDIA ModelOpt FP8 weight checkpoints are rejected before launch:
125+
vLLM Metal cannot load that tensor layout, so macOS needs a BF16, FP16, or
126+
MLX-compatible quantized checkpoint instead.
127+
107128
Readiness polls `/health` for up to eight minutes while weights load.
108129

109130
The model list is the Hugging Face cache (`~/.cache/huggingface/hub`), not what

‎services/nvpair-engine-manager/launchauto.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"os/exec"
1414
"path/filepath"
15+
"runtime"
1516
"strconv"
1617
"strings"
1718
"time"
@@ -126,6 +127,10 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir
126127
}
127128
hub := filepath.Join(expandPath(modelsDir), "hub")
128129
extra, note := planHFContext(command.Args, hub, int64(rt.AutoLaunch.ContextTokens), command.Bin)
130+
if kvCache, reason := planMetalKVCache(command.Args, hub, runtime.GOOS); len(kvCache) > 0 {
131+
extra = append(extra, kvCache...)
132+
note = strings.TrimPrefix(note+"; "+reason, "; ")
133+
}
129134
if tools, parser := planToolCalling(command.Args, hub, command.Bin); len(tools) > 0 {
130135
extra = append(extra, tools...)
131136
note = strings.TrimPrefix(note+"; tool calls parsed with "+parser, "; ")
@@ -138,6 +143,14 @@ func applyAutoLaunch(engine string, rt Runtime, command launchCommand, modelsDir
138143
return command
139144
}
140145

146+
func validateAutoLaunch(rt Runtime, command launchCommand, modelsDir string) error {
147+
if rt.AutoLaunch == nil || rt.AutoLaunch.Kind != autoLaunchHFContext {
148+
return nil
149+
}
150+
hub := filepath.Join(expandPath(modelsDir), "hub")
151+
return validateMetalModel(command.Args, hub, runtime.GOOS)
152+
}
153+
141154
// launchFlagValue returns the value of a flag in either "--flag value" or
142155
// "--flag=value" form, and whether the flag is present at all.
143156
func launchFlagValue(args []string, flag string) (string, bool) {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
)
13+
14+
type modelOptConfig struct {
15+
Producer struct {
16+
Name string `json:"name"`
17+
} `json:"producer"`
18+
Quantization struct {
19+
QuantAlgo string `json:"quant_algo"`
20+
KVCacheQuantAlgo string `json:"kv_cache_quant_algo"`
21+
} `json:"quantization"`
22+
}
23+
24+
func planMetalKVCache(args []string, hubDir, goos string) (extra []string, note string) {
25+
if goos != "darwin" {
26+
return nil, ""
27+
}
28+
if _, set := launchFlagValue(args, "--kv-cache-dtype"); set {
29+
return nil, ""
30+
}
31+
model, ok := launchFlagValue(args, "--model")
32+
if !ok || model == "" {
33+
return nil, ""
34+
}
35+
snapshot, cfg, _, cached := findHFSnapshot(hubDir, model)
36+
if !cached || modelOptFP8Weights(snapshot) || !quantizedKVCache(snapshot) {
37+
return nil, ""
38+
}
39+
dtype := metalDenseKVCacheDtype(cfg)
40+
if dtype == "" {
41+
return nil, ""
42+
}
43+
return []string{"--kv-cache-dtype", dtype}, "Metal requires the KV cache to use the model dtype " + dtype
44+
}
45+
46+
func validateMetalModel(args []string, hubDir, goos string) error {
47+
if goos != "darwin" {
48+
return nil
49+
}
50+
model, ok := launchFlagValue(args, "--model")
51+
if !ok || model == "" {
52+
return nil
53+
}
54+
snapshot, _, _, cached := findHFSnapshot(hubDir, model)
55+
if !cached || !modelOptFP8Weights(snapshot) {
56+
return nil
57+
}
58+
return fmt.Errorf("model %q uses NVIDIA ModelOpt FP8 weights, which vLLM Metal cannot load on macOS; use a BF16, FP16, or MLX-compatible quantized checkpoint", model)
59+
}
60+
61+
func metalDenseKVCacheDtype(cfg hfModelConfig) string {
62+
text := cfg
63+
if cfg.TextConfig != nil {
64+
text = *cfg.TextConfig
65+
}
66+
for _, value := range []string{text.Dtype, text.TorchDtype, cfg.Dtype, cfg.TorchDtype} {
67+
switch strings.TrimPrefix(strings.ToLower(value), "torch.") {
68+
case "bfloat16", "bf16":
69+
return "bfloat16"
70+
case "float16", "fp16", "half":
71+
return "float16"
72+
}
73+
}
74+
return ""
75+
}
76+
77+
func quantizedKVCache(snapshot string) bool {
78+
if modelOpt, ok := readModelOptConfig(snapshot); ok && kvAlgorithmSet(modelOpt.Quantization.KVCacheQuantAlgo) {
79+
return true
80+
}
81+
var config struct {
82+
Quantization struct {
83+
KVCacheQuantAlgo string `json:"kv_cache_quant_algo"`
84+
KVCacheScheme json.RawMessage `json:"kv_cache_scheme"`
85+
} `json:"quantization_config"`
86+
}
87+
if !readJSONFile(filepath.Join(snapshot, "config.json"), &config) {
88+
return false
89+
}
90+
scheme := strings.TrimSpace(string(config.Quantization.KVCacheScheme))
91+
return kvAlgorithmSet(config.Quantization.KVCacheQuantAlgo) || (scheme != "" && scheme != "null")
92+
}
93+
94+
func modelOptFP8Weights(snapshot string) bool {
95+
modelOpt, ok := readModelOptConfig(snapshot)
96+
return ok && strings.EqualFold(modelOpt.Producer.Name, "modelopt") && strings.EqualFold(modelOpt.Quantization.QuantAlgo, "fp8")
97+
}
98+
99+
func readModelOptConfig(snapshot string) (modelOptConfig, bool) {
100+
var config modelOptConfig
101+
return config, readJSONFile(filepath.Join(snapshot, "hf_quant_config.json"), &config)
102+
}
103+
104+
func kvAlgorithmSet(algorithm string) bool {
105+
return algorithm != "" && !strings.EqualFold(algorithm, "none")
106+
}
107+
108+
func readJSONFile(path string, into any) bool {
109+
raw, err := os.ReadFile(path)
110+
return err == nil && json.Unmarshal(raw, into) == nil
111+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
)
12+
13+
const (
14+
modelOptFP8KVOnly = `{"producer":{"name":"modelopt"},"quantization":{"kv_cache_quant_algo":"FP8"}}`
15+
modelOptFP8WeightsJSON = `{"producer":{"name":"modelopt"},"quantization":{"quant_algo":"FP8","kv_cache_quant_algo":"FP8"}}`
16+
)
17+
18+
func cachedModelWithFile(t *testing.T, id, config, name, contents string) string {
19+
t.Helper()
20+
cache := cachedModel(t, id, config, 1_000_000)
21+
snapshot, _, _, ok := findHFSnapshot(cache, id)
22+
if !ok {
23+
t.Fatal("cached model has no complete snapshot")
24+
}
25+
if err := os.WriteFile(filepath.Join(snapshot, name), []byte(contents), 0o600); err != nil {
26+
t.Fatal(err)
27+
}
28+
return cache
29+
}
30+
31+
func TestPlanMetalKVCache_UsesBFloat16ForModelOptFP8KVCache(t *testing.T) {
32+
cache := cachedModelWithFile(t, "org/Llama-KV-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8KVOnly)
33+
34+
extra, note := planMetalKVCache([]string{"serve", "--model", "org/Llama-KV-FP8"}, cache, "darwin")
35+
36+
if got := flagOf(t, extra, "--kv-cache-dtype"); got != "bfloat16" {
37+
t.Fatalf("kv-cache-dtype = %q, want bfloat16: %v", got, extra)
38+
}
39+
if !strings.Contains(note, "model dtype bfloat16") {
40+
t.Fatalf("note = %q", note)
41+
}
42+
}
43+
44+
func TestPlanMetalKVCache_UsesFloat16ForCompressedKVCache(t *testing.T) {
45+
config := `{"max_position_embeddings":40960,"num_hidden_layers":32,"num_attention_heads":32,"num_key_value_heads":8,"head_dim":128,"torch_dtype":"float16","quantization_config":{"quant_method":"compressed-tensors","kv_cache_scheme":{"type":"float","num_bits":8}}}`
46+
cache := cachedModel(t, "org/compressed", config, 1_000_000)
47+
48+
extra, _ := planMetalKVCache([]string{"--model", "org/compressed"}, cache, "darwin")
49+
50+
if got := flagOf(t, extra, "--kv-cache-dtype"); got != "float16" {
51+
t.Fatalf("kv-cache-dtype = %q, want float16: %v", got, extra)
52+
}
53+
}
54+
55+
func TestPlanMetalKVCache_PreservesAnExplicitUserChoice(t *testing.T) {
56+
cache := cachedModelWithFile(t, "org/Llama-KV-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8KVOnly)
57+
args := []string{"--model", "org/Llama-KV-FP8", "--kv-cache-dtype=auto"}
58+
59+
extra, _ := planMetalKVCache(args, cache, "darwin")
60+
61+
if len(extra) != 0 {
62+
t.Fatalf("the user's KV cache choice must stand untouched: %v", extra)
63+
}
64+
}
65+
66+
func TestPlanMetalKVCache_AddsNothingWithoutAPlatformConflict(t *testing.T) {
67+
quantized := cachedModelWithFile(t, "org/Llama-KV-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8KVOnly)
68+
plain := cachedModel(t, "org/plain", llama8BFP8Config, 1_000_000)
69+
unsupportedDtype := cachedModelWithFile(t, "org/fp32-kv", strings.Replace(llama8BFP8Config, "bfloat16", "float32", 1), "hf_quant_config.json", modelOptFP8KVOnly)
70+
71+
cases := []struct {
72+
name string
73+
args []string
74+
cache string
75+
goos string
76+
}{
77+
{"non-Metal platform", []string{"--model", "org/Llama-KV-FP8"}, quantized, "linux"},
78+
{"dense checkpoint", []string{"--model", "org/plain"}, plain, "darwin"},
79+
{"dtype unsupported by Metal", []string{"--model", "org/fp32-kv"}, unsupportedDtype, "darwin"},
80+
}
81+
for _, tc := range cases {
82+
t.Run(tc.name, func(t *testing.T) {
83+
extra, _ := planMetalKVCache(tc.args, tc.cache, tc.goos)
84+
if len(extra) != 0 {
85+
t.Fatalf("got %v, want no automatic KV cache override", extra)
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestPlanMetalKVCache_SkipsModelOptFP8Weights(t *testing.T) {
92+
cache := cachedModelWithFile(t, "nvidia/Llama-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8WeightsJSON)
93+
94+
extra, _ := planMetalKVCache([]string{"--model", "nvidia/Llama-FP8"}, cache, "darwin")
95+
96+
if len(extra) != 0 {
97+
t.Fatalf("an unsupported checkpoint must not receive a misleading KV-cache fallback: %v", extra)
98+
}
99+
}
100+
101+
func TestValidateMetalModel_RejectsModelOptFP8WeightsOnMacOS(t *testing.T) {
102+
cache := cachedModelWithFile(t, "nvidia/Llama-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8WeightsJSON)
103+
104+
err := validateMetalModel([]string{"--model", "nvidia/Llama-FP8"}, cache, "darwin")
105+
106+
if err == nil {
107+
t.Fatal("ModelOpt FP8 weights must be rejected before vLLM Metal starts")
108+
}
109+
for _, want := range []string{"nvidia/Llama-FP8", "ModelOpt FP8", "BF16", "MLX-compatible"} {
110+
if !strings.Contains(err.Error(), want) {
111+
t.Fatalf("error = %q, want %q", err, want)
112+
}
113+
}
114+
}
115+
116+
func TestValidateMetalModel_AllowsModelOptFP8WeightsOutsideMacOS(t *testing.T) {
117+
cache := cachedModelWithFile(t, "nvidia/Llama-FP8", llama8BFP8Config, "hf_quant_config.json", modelOptFP8WeightsJSON)
118+
119+
err := validateMetalModel([]string{"--model", "nvidia/Llama-FP8"}, cache, "linux")
120+
121+
if err != nil {
122+
t.Fatalf("non-Metal platforms keep their native FP8 support: %v", err)
123+
}
124+
}

‎services/nvpair-engine-manager/lifecycle.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,9 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s
283283
if err := validateEffectiveLaunch(rt, launch, vars["host"], vars["port"]); err != nil {
284284
return err
285285
}
286+
if err := validateAutoLaunch(rt, launch, vars["models_dir"]); err != nil {
287+
return err
288+
}
286289
launch = applyAutoLaunch(engine, rt, launch, vars["models_dir"])
287290
if launch, err = applyFrontBackend(rt, launch, port); err != nil {
288291
return err

0 commit comments

Comments
 (0)