diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index 83c25e6..2eeb2c7 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -5,12 +5,13 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const YAML = require("yaml"); const { modelNames, versionFiles, variantFiles, kindOf } = require("./catalog"); +const { valueSetAt } = require("./inputs"); function affectsValidator(file) { return ( file.startsWith("schema/crds/") || /^hack\/(validate-helm|install-helm-tools|build-crds)(\.integration)?\.(js|test\.js)$/.test(file) || - /^hack\/lib\/(helm-validation|crd-schema|download|catalog)(\.test)?\.js$/.test(file) || + /^hack\/lib\/(helm-validation|crd-schema|download|catalog|inputs)(\.test)?\.js$/.test(file) || ["package.json", "package-lock.json", ".github/workflows/lint.yml"].includes(file) ); } @@ -122,9 +123,9 @@ function parseVersions(file) { // variant spells first-class projected on top, so they win over the same keys // in values. image.digest is not projected. // -// Each declared input is set to its default, else its example, standing in for -// the answer swiss puts in the derived layer at deploy, so the chart renders -// every path swiss fills. values never sets one; validate:schema refuses that. +// Each declared input renders as swiss composes it with no site answer: its +// default, else the value values gives it, else its example, standing in for +// the answer a deploy gives. function catalogValues(variant) { const values = structuredClone(variant.values ?? {}); const set = (keys, value) => { @@ -140,7 +141,11 @@ function catalogValues(variant) { set(["image", "repository"], variant.image.repository); set(["image", "tag"], String(variant.image.tag ?? "")); } - for (const input of variant.inputs ?? []) set(input.key.split("."), "default" in input ? input.default : input.example); + for (const input of variant.inputs ?? []) { + const keys = input.key.split("."); + if ("default" in input) set(keys, input.default); + else if (valueSetAt(values, keys) !== input.key) set(keys, input.example); + } return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index ca08bd3..c1052a1 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -197,19 +197,21 @@ test("variant values get swiss's projection of servedName, requires.gpus and the assert.deepEqual(catalogValues(v2), { model: { name: "served", gpus: "2" } }); }); -test("a declared input renders with its default, else its example", () => { +test("a declared input renders with its default, else the value values gives it, else its example", () => { const variant = { servedName: "m", requires: { gpus: 8 }, inputs: [ { key: "storageKVOffload.mooncake.master", title: "t", example: "mooncake-master.example:50051" }, { key: "storageKVOffload.mooncake.device", title: "t", example: "mlx5_0", default: "mlx5_3" }, + { key: "storageKVOffload.mooncake.te_meta_data_server", title: "t", example: "http://x.example/m" }, + { key: "storageKVOffload.mooncake.global_segment_size", title: "t", example: "2gb", default: "4gb" }, ], - values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }, + values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma", te_meta_data_server: "", global_segment_size: "1gb" } } }, }; assert.deepEqual(catalogValues(variant).storageKVOffload, { enabled: true, - mooncake: { protocol: "rdma", master: "mooncake-master.example:50051", device: "mlx5_3" }, + mooncake: { protocol: "rdma", master: "mooncake-master.example:50051", device: "mlx5_3", te_meta_data_server: "", global_segment_size: "4gb" }, }); assert.equal(variant.values.storageKVOffload.mooncake.master, undefined); }); diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js index b3bc384..2cf603c 100644 --- a/hack/lib/inputs.js +++ b/hack/lib/inputs.js @@ -1,8 +1,9 @@ "use strict"; -// A variant's inputs, past what the schema can see. A key is declared once, and -// values leaves it alone: swiss puts the answer, the site's or else the input's -// default, in the derived layer. The example and the default are valid answers. -// swiss repeats these checks when it loads a variant (internal/catalog). +// A variant's inputs, past what the schema can see. A key is declared once. A +// value values gives it is the base an answer overrides: swiss puts the answer, +// the site's or else the input's default, in the derived layer above it. The +// example and the default are valid answers. swiss repeats these checks when it +// loads a variant (internal/catalog). // Problems with one variant's inputs, each line prefixed with at. function inputProblems(variant, at = "") { @@ -15,7 +16,7 @@ function inputProblems(variant, at = "") { if (declared.has(input.key)) lines.push(`${where}declared twice`); declared.add(input.key); const set = valueSetAt(variant.values, input.key.split(".")); - if (set) lines.push(`${where}values.${set} sets it; give the value as the input's default`); + if (set && set !== input.key) lines.push(`${where}values.${set} is not a map, so the answer has no place to go`); const type = input.type ?? "string"; let pattern; if (input.pattern !== undefined && type !== "string") { @@ -55,4 +56,4 @@ function valueSetAt(values, keys) { return keys.join("."); } -module.exports = { inputProblems }; +module.exports = { inputProblems, valueSetAt }; diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js index 752d1a0..5b1f586 100644 --- a/hack/lib/inputs.test.js +++ b/hack/lib/inputs.test.js @@ -18,16 +18,13 @@ test("a declared input that values leaves alone, with a valid example and defaul assert.deepEqual(inputProblems({ requires: { gpus: 1 } }), []); }); -test("values may not set an input: at its key, under it, or with a non-map on the way", () => { - for (const [values, at] of [ - [{ storageKVOffload: { mooncake: { master: "mc:50051" } } }, "storageKVOffload.mooncake.master"], - [{ storageKVOffload: { mooncake: { master: { host: "h" } } } }, "storageKVOffload.mooncake.master"], - [{ storageKVOffload: { mooncake: "off" } }, "storageKVOffload.mooncake"], - ]) { - assert.deepEqual(inputProblems(variant([master], values), "v: "), [ - `v: inputs[0] storageKVOffload.mooncake.master: values.${at} sets it; give the value as the input's default`, - ]); +test("values may set an input's key, as the base an answer overrides, but no non-map above it", () => { + for (const value of ["mooncake-master.example:50051", ""]) { + assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { mooncake: { master: value } } })), []); } + assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { mooncake: "off" } }), "v: "), [ + "v: inputs[0] storageKVOffload.mooncake.master: values.storageKVOffload.mooncake is not a map, so the answer has no place to go", + ]); }); test("the default is an answer: of the input's type, matching its pattern", () => { diff --git a/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-l3-6000d.yaml b/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-l3-6000d.yaml index b479cea..36cc2b6 100644 --- a/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-l3-6000d.yaml +++ b/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-l3-6000d.yaml @@ -2,7 +2,7 @@ apiVersion: catalog.swiss/v2 kind: Variant model: deepseek-v4-flash-0731-nvfp4 id: sglang-tp8-l3-6000d -version: 1.0.6 +version: 1.0.7 servedName: deepseek-v4-flash-0731-nvfp4 engine: sglang description: >- @@ -34,6 +34,21 @@ requires: # Not expressible in this block, but required on the node: # * ~650 GiB of 2 MiB hugepages reserved AND advertised by kubelet # * routes to the EBOF targets on the storage NICs +inputs: + - key: storageKVOffload.mooncake.master + title: Mooncake master + description: host:port of the standalone Mooncake master, outside Kubernetes, owning NoF volumes no other master holds. + pattern: '[^\s:/]+:[0-9]+' + example: mooncake-master.example:50051 + - key: storageKVOffload.mooncake.te_meta_data_server + title: Mooncake metadata server + description: The transfer engine's metadata server URL, or P2PHANDSHAKE. + pattern: 'https?://\S+|P2PHANDSHAKE' + example: http://mooncake-master.example:8080/metadata + - key: storageKVOffload.mooncake.device + title: Mooncake storage HCAs + description: The storage NICs, comma-separated. Left to auto-detect, Mooncake picked the management NIC on the cluster this was tuned on. + example: mlx5_0,mlx5_1 values: model: localPath: /data/models/DeepSeek-V4-Flash-0731-NVFP4 diff --git a/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-pd-l3-6000d.yaml b/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-pd-l3-6000d.yaml index 5489beb..697815d 100644 --- a/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-pd-l3-6000d.yaml +++ b/models/deepseek-v4-flash-0731-nvfp4/sglang-tp8-pd-l3-6000d.yaml @@ -2,7 +2,7 @@ apiVersion: catalog.swiss/v2 kind: Variant model: deepseek-v4-flash-0731-nvfp4 id: sglang-tp8-pd-l3-6000d -version: 1.0.4 +version: 1.0.5 servedName: deepseek-v4-flash-0731-nvfp4 engine: sglang description: >- @@ -41,6 +41,21 @@ requires: # ebof.modelsphere.io/storage-net=true; pd.prefill.affinityReplace pins them): # * ~650 GiB of 2 MiB hugepages reserved AND advertised by kubelet # * routes to the EBOF targets on the storage NICs +inputs: + - key: storageKVOffload.mooncake.master + title: Mooncake master + description: host:port of the standalone Mooncake master, outside Kubernetes, owning NoF volumes no other master holds. + pattern: '[^\s:/]+:[0-9]+' + example: mooncake-master.example:50051 + - key: storageKVOffload.mooncake.te_meta_data_server + title: Mooncake metadata server + description: The transfer engine's metadata server URL, or P2PHANDSHAKE. + pattern: 'https?://\S+|P2PHANDSHAKE' + example: http://mooncake-master.example:8080/metadata + - key: storageKVOffload.mooncake.device + title: Mooncake storage HCAs + description: The storage NICs, comma-separated. Left to auto-detect, Mooncake picked the management NIC on the cluster this was tuned on. + example: mlx5_0,mlx5_1 values: model: localPath: /data/models/DeepSeek-V4-Flash-0731-NVFP4 diff --git a/models/glm5.3/sglang-tp8-pp2-ep8-pd-l3-6000d.yaml b/models/glm5.3/sglang-tp8-pp2-ep8-pd-l3-6000d.yaml index 1b12f84..9b0f0fd 100644 --- a/models/glm5.3/sglang-tp8-pp2-ep8-pd-l3-6000d.yaml +++ b/models/glm5.3/sglang-tp8-pp2-ep8-pd-l3-6000d.yaml @@ -2,7 +2,7 @@ apiVersion: catalog.swiss/v2 kind: Variant model: glm5.3 id: sglang-tp8-pp2-ep8-pd-l3-6000d -version: 1.0.5 +version: 1.0.6 servedName: glm-5.3 engine: sglang description: >- @@ -24,6 +24,21 @@ requires: rdma: true gpuProduct: - NVIDIA-RTX-6000D +inputs: + - key: storageKVOffload.mooncake.master + title: Mooncake master + description: host:port of the Mooncake Store master this KV tier uses. + pattern: '[^\s:/]+:[0-9]+' + example: mooncake-master.example:50051 + - key: storageKVOffload.mooncake.te_meta_data_server + title: Mooncake metadata server + description: The transfer engine's metadata server URL, or P2PHANDSHAKE. + pattern: 'https?://\S+|P2PHANDSHAKE' + example: http://mooncake-master.example:8080/metadata + - key: storageKVOffload.mooncake.device + title: Mooncake storage HCAs + description: The storage NICs, comma-separated. Empty lets Mooncake pick. + example: mlx5_0,mlx5_1 values: model: localPath: /data/models/GLM-5.3-NVFP4 diff --git a/schema/version.schema.json b/schema/version.schema.json index 92980b2..cb2e377 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -169,7 +169,7 @@ ] }, "inputs": { - "description": "Chart values every deploy must answer, such as the address of a service the site runs. Unlike requires, which swiss interprets and checks against the cluster, an input names one chart value by its key, and swiss only relays its answer: swiss puts the answer in the derived layer -- the site profile's inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. values never sets an input's key. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "description": "Chart values every deploy must answer, such as the address of a service the site runs. Unlike requires, which swiss interprets and checks against the cluster, an input names one chart value by its key, and swiss only relays its answer: swiss puts the answer in the derived layer -- the site profile's inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. A value values gives an input's key is the base its answer overrides. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", "type": "array", "minItems": 1, "items": {