Selaa lähdekoodia

feat: SD.cpp Load Model takes every load option, and reloads when they differ

The node only ever compared the model NAME. The same model loaded with
streaming off, or onto a different backend, is not the model a workflow asked
for - and the server swaps models between queue items, so what is loaded now may
have been put there by something else entirely.

All 21 options that /models/load accepts are now settings on the node: flash
attention for CLIP and for the diffusion model separately, memory mapping, eager
load, layer streaming and its VRAM budget, thread count, weight type, VAE
format, prediction type, both RNG types, LoRA apply mode, the direct convolution
paths, TAESD-for-preview-only, backend and parameter placement, RPC servers,
model args and tensor type rules. The free-form passthrough stays for anything
the server adds later. They are grouped - Connection, Model, Components,
Loading, Advanced - because thirty-six settings in one list is not a form.

Only settings that were actually filled in are sent or compared. An untouched
setting means "whatever the server does", not "the default", so a workflow that
cares about streaming and nothing else says exactly that.

The idempotency check now covers both: the right model with the wrong settings
is reloaded, and the node reports which settings forced it in reloadedFor. With
When A Different Model Is Loaded set to fail, it refuses and names the
differences instead - so a workflow can assert a server is set up correctly
rather than quietly spending minutes rearranging it.

SD.cpp Health now reports loadOptions, which is what the loaded model was loaded
WITH rather than only which model it is. Without it the prefill had nothing to
read: it filled four settings and silently skipped the twenty-one it could not
find.

Verified against the live server: Fill in reported sixteen settings filled -
every option the server reports, plus the model, its type and its components -
asking for streamLayers false while the server has it true fails naming the
difference, and asking for the settings it actually has does not reload. Full
suite 58/58.
fszontagh 1 kuukausi sitten
vanhempi
sitoutus
7c8cae15ec

+ 6 - 0
nodes/sdcpp/sdcpp-health.js

@@ -69,6 +69,7 @@ const outputSchema = {
         upscalerLoaded: { type: 'boolean' },
         upscalerName: { type: 'string' },
         loadedComponents: { type: 'object' },
+        loadOptions: { type: 'object', description: 'The settings the loaded model was loaded with - flash attention, streaming, VRAM budget and the rest' },
         memory: { type: 'object', description: 'Memory snapshot the server reports' },
         features: { type: 'object', description: 'Feature flags, including whether authentication is required' },
         version: { type: 'string' },
@@ -102,6 +103,7 @@ function unhealthy(server, reason, responseMs) {
         upscalerLoaded: false,
         upscalerName: '',
         loadedComponents: {},
+        loadOptions: {},
         memory: {},
         features: {},
         version: '',
@@ -191,6 +193,10 @@ async function execute(config, input, context) {
         upscalerLoaded: health.upscaler_loaded === true,
         upscalerName: health.upscaler_name || '',
         loadedComponents: health.loaded_components || {},
+        // What the model was loaded WITH, not just which model it is. A node
+        // that has to guarantee particular settings needs this to tell whether
+        // the right model is loaded the right way.
+        loadOptions: health.load_options || {},
         memory: health.memory || {},
         features: health.features || {},
         version: health.version || '',

+ 176 - 11
nodes/sdcpp/sdcpp-model-load.js

@@ -25,6 +25,17 @@ const credentialTypes = [
 
 const configSchema = {
     type: 'object',
+    uiGroups: [
+        { title: 'Connection', fields: ['serverUrl', 'credentialId'] },
+        { title: 'Model', fields: ['modelName', 'modelType', 'whenDifferent', 'force'] },
+        { title: 'Components', fields: ['vae', 'clipL', 'clipG', 't5xxl', 'llm', 'taesd', 'controlnet'] },
+        { title: 'Loading', fields: ['flashAttn', 'diffusionFlashAttn', 'enableMmap', 'eagerLoad',
+                                     'streamLayers', 'maxVram', 'nThreads', 'weightType'] },
+        { title: 'Advanced', fields: ['vaeFormat', 'prediction', 'rngType', 'samplerRngType',
+                                      'loraApplyMode', 'vaeConvDirect', 'diffusionConvDirect',
+                                      'taePreviewOnly', 'backend', 'paramsBackend', 'rpcServers',
+                                      'modelArgs', 'tensorTypeRules', 'options', 'timeout'] }
+    ],
     // Pressing this asks the server what it has loaded and writes it into the
     // settings below - the model and every component it reports - so a workflow
     // can be built from a server that is already set up the way it should be,
@@ -43,7 +54,24 @@ const configSchema = {
             'loadedComponents.t5xxl': 't5xxl',
             'loadedComponents.llm': 'llm',
             'loadedComponents.taesd': 'taesd',
-            'loadedComponents.controlnet': 'controlnet'
+            'loadedComponents.controlnet': 'controlnet',
+            'loadOptions.flash_attn': 'flashAttn',
+            'loadOptions.diffusion_flash_attn': 'diffusionFlashAttn',
+            'loadOptions.enable_mmap': 'enableMmap',
+            'loadOptions.eager_load': 'eagerLoad',
+            'loadOptions.stream_layers': 'streamLayers',
+            'loadOptions.max_vram': 'maxVram',
+            'loadOptions.n_threads': 'nThreads',
+            'loadOptions.vae_format': 'vaeFormat',
+            'loadOptions.rng_type': 'rngType',
+            'loadOptions.lora_apply_mode': 'loraApplyMode',
+            'loadOptions.vae_conv_direct': 'vaeConvDirect',
+            'loadOptions.diffusion_conv_direct': 'diffusionConvDirect',
+            'loadOptions.tae_preview_only': 'taePreviewOnly',
+            'loadOptions.backend': 'backend',
+            'loadOptions.params_backend': 'paramsBackend',
+            'loadOptions.rpc_servers': 'rpcServers',
+            'loadOptions.model_args': 'modelArgs'
         }
     },
     properties: {
@@ -167,8 +195,50 @@ const configSchema = {
                 needs: ['serverUrl', 'credentialId']
             }
         },
+        flashAttn: { type: 'boolean', title: 'Flash Attention', description: 'For CLIP and T5. A large speed and memory win on modern GPUs' },
+        diffusionFlashAttn: { type: 'boolean', title: 'Flash Attention (diffusion)', description: 'Flash attention for the diffusion model specifically' },
+        enableMmap: { type: 'boolean', title: 'Memory-map Weights', description: 'Recommended for large files' },
+        eagerLoad: { type: 'boolean', title: 'Eager Load', description: 'Move every parameter to the compute backend at load time instead of on demand' },
+        streamLayers: { type: 'boolean', title: 'Stream Layers', description: 'Stream diffusion layers when the model does not fit in VRAM. Pair with a VRAM budget' },
+        maxVram: { type: 'number', title: 'VRAM Budget (GiB)', description: 'Budget for segmented parameter offload. 0 leaves it to the server' },
+        nThreads: { type: 'number', title: 'CPU Threads', description: '-1 lets the server decide' },
+        weightType: {
+            type: 'string', title: 'Weight Type',
+            enum: ['', 'f32', 'f16', 'bf16', 'q8_0', 'q5_0', 'q5_1', 'q4_0', 'q4_1', 'q4_k', 'q5_k', 'q6_k', 'q8_k', 'q3_k', 'q2_k', 'mxfp4', 'nvfp4', 'q1_0'],
+            default: '', description: 'Force a quantisation. Empty takes it from the file'
+        },
+        vaeFormat: {
+            type: 'string', title: 'VAE Format',
+            enum: ['', 'auto', 'flux', 'sd3', 'flux2', 'wan'],
+            default: '', description: 'Override VAE format detection'
+        },
+        prediction: {
+            type: 'string', title: 'Prediction Type',
+            enum: ['', 'eps', 'v', 'edm_v', 'sd3_flow', 'flux_flow', 'flux2_flow', 'sefi_flow', 'minit2i_flow'],
+            default: '', description: 'Override the prediction type. Empty is detected'
+        },
+        rngType: {
+            type: 'string', title: 'RNG', enum: ['', 'cuda', 'std_default', 'cpu'],
+            default: '', description: 'Affects whether a seed reproduces across backends'
+        },
+        samplerRngType: {
+            type: 'string', title: 'Sampler RNG', enum: ['', 'cuda', 'std_default', 'cpu'],
+            default: '', description: 'Override the RNG for sampling only'
+        },
+        loraApplyMode: {
+            type: 'string', title: 'LoRA Apply Mode', enum: ['', 'auto', 'immediately', 'at_runtime'],
+            default: '', description: 'When a LoRA named in a prompt is applied'
+        },
+        vaeConvDirect: { type: 'boolean', title: 'Direct VAE Convolution', description: 'Use the ggml_conv2d_direct path for the VAE' },
+        diffusionConvDirect: { type: 'boolean', title: 'Direct Diffusion Convolution', description: 'Use the ggml_conv2d_direct path for the diffusion model' },
+        taePreviewOnly: { type: 'boolean', title: 'TAESD For Preview Only', description: 'Load TAESD purely to render progress previews, skipping the full VAE' },
+        backend: { type: 'string', title: 'Backend', description: 'Per-component placement, such as te=cpu,vae=cpu,controlnet=cpu' },
+        paramsBackend: { type: 'string', title: 'Parameter Backend', description: 'Global parameter placement, such as *=cpu to hold weights in system RAM' },
+        rpcServers: { type: 'string', title: 'RPC Servers', description: 'Comma separated RPC backend endpoints' },
+        modelArgs: { type: 'string', title: 'Model Args', description: 'Architecture-specific key=value knobs, comma separated' },
+        tensorTypeRules: { type: 'string', title: 'Tensor Type Rules', description: 'Per-tensor weight overrides using regex, such as ^vae\\.=f16' },
         options: {
-            type: 'object', title: 'Load Options',
+            type: 'object', title: 'Other Load Options',
             description: 'Extra load options passed through, such as flash_attn, enable_mmap, weight_type, stream_layers or max_vram'
         },
         whenDifferent: {
@@ -201,7 +271,8 @@ const outputSchema = {
         modelType: { type: 'string' },
         architecture: { type: 'string', description: 'Architecture the server detected, which decides generation defaults' },
         loaded: { type: 'boolean', description: 'True when this node performed a load' },
-        alreadyLoaded: { type: 'boolean', description: 'True when the right model was already in place and nothing was done' },
+        alreadyLoaded: { type: 'boolean', description: 'True when the right model, with the right settings, was already in place' },
+        reloadedFor: { type: 'array', description: 'Settings that differed on an otherwise-correct model, when that is why it was reloaded' },
         previousModel: { type: 'string', description: 'What was loaded before, when this node swapped it' },
         loadedComponents: { type: 'object' },
         elapsedMs: { type: 'number' }
@@ -300,6 +371,81 @@ function putIfSet(target, key, value) {
     target[key] = value;
 }
 
+const LOAD_OPTIONS = [
+    { setting: 'flashAttn', server: 'flash_attn' },
+    { setting: 'diffusionFlashAttn', server: 'diffusion_flash_attn' },
+    { setting: 'enableMmap', server: 'enable_mmap' },
+    { setting: 'eagerLoad', server: 'eager_load' },
+    { setting: 'streamLayers', server: 'stream_layers' },
+    { setting: 'maxVram', server: 'max_vram' },
+    { setting: 'nThreads', server: 'n_threads' },
+    { setting: 'weightType', server: 'weight_type' },
+    { setting: 'vaeFormat', server: 'vae_format' },
+    { setting: 'prediction', server: 'prediction' },
+    { setting: 'rngType', server: 'rng_type' },
+    { setting: 'samplerRngType', server: 'sampler_rng_type' },
+    { setting: 'loraApplyMode', server: 'lora_apply_mode' },
+    { setting: 'vaeConvDirect', server: 'vae_conv_direct' },
+    { setting: 'diffusionConvDirect', server: 'diffusion_conv_direct' },
+    { setting: 'taePreviewOnly', server: 'tae_preview_only' },
+    { setting: 'backend', server: 'backend' },
+    { setting: 'paramsBackend', server: 'params_backend' },
+    { setting: 'rpcServers', server: 'rpc_servers' },
+    { setting: 'modelArgs', server: 'model_args' },
+    { setting: 'tensorTypeRules', server: 'tensor_type_rules' },
+];
+
+
+// The options this node asks for, as the server names them. Only settings that
+// were actually filled in are included: an untouched setting means "whatever
+// the server does", not "the default", so it is neither sent nor compared.
+function wantedOptions(config) {
+    var wanted = {};
+    for (var i = 0; i < LOAD_OPTIONS.length; i++) {
+        var entry = LOAD_OPTIONS[i];
+        var value = config[entry.setting];
+        if (value === undefined || value === null || value === '') continue;
+        if (typeof value === 'number' && !isFinite(value)) continue;
+        wanted[entry.server] = value;
+    }
+    if (config.options && typeof config.options === 'object') {
+        var keys = Object.keys(config.options);
+        for (var k = 0; k < keys.length; k++) {
+            var extra = config.options[keys[k]];
+            if (extra !== undefined && extra !== null && extra !== '') {
+                wanted[keys[k]] = extra;
+            }
+        }
+    }
+    return wanted;
+}
+
+// Which of them the server is not currently loaded with.
+//
+// This is what makes the node able to correct a server someone else changed:
+// the same model loaded with streaming off is not the same thing as the model
+// this workflow needs, and reloading it is the whole point of saying so here.
+function optionsThatDiffer(wanted, current) {
+    var differing = [];
+    var keys = Object.keys(wanted);
+    for (var i = 0; i < keys.length; i++) {
+        var key = keys[i];
+        var have = current ? current[key] : undefined;
+        var want = wanted[key];
+        // Numbers arrive as 0 or 0.0 depending on the field, and a boolean may
+        // come back as a string from a form, so compare on value rather than
+        // on type.
+        var same = (typeof want === 'number' || typeof have === 'number')
+            ? Number(have) === Number(want)
+            : String(have) === String(want);
+        if (!same) {
+            differing.push(key + ': server has ' + JSON.stringify(have) +
+                           ', this node wants ' + JSON.stringify(want));
+        }
+    }
+    return differing;
+}
+
 async function execute(config, input, context) {
     const server = normalizeServer(config.serverUrl);
     const timeout = config.timeout > 0 ? config.timeout : 300000;
@@ -318,24 +464,40 @@ async function execute(config, input, context) {
     const current = health.model_name || '';
     const sameModel = current === modelName;
 
-    if (sameModel && config.force !== true) {
-        smartbotic.log.info('SD.cpp: ' + modelName + ' is already loaded, nothing to do');
+    // The same model loaded with different settings is not the model this
+    // workflow asked for. The server swaps models between queue items, so what
+    // is loaded now may have been put there by something else entirely.
+    const wanted = wantedOptions(config);
+    const differing = optionsThatDiffer(wanted, health.load_options || {});
+
+    if (sameModel && differing.length === 0 && config.force !== true) {
+        smartbotic.log.info('SD.cpp: ' + modelName + ' is already loaded with the wanted settings');
         return {
             modelName: current,
             modelType: health.model_type || '',
             architecture: health.model_architecture || '',
             loaded: false,
             alreadyLoaded: true,
+            reloadedFor: [],
             previousModel: '',
             loadedComponents: health.loaded_components || {},
             elapsedMs: Date.now() - startedAt
         };
     }
 
-    if (!sameModel && (config.whenDifferent || 'load') === 'fail') {
-        throw new Error('SD.cpp: this workflow expects "' + modelName + '" to be loaded, but ' +
-            (current ? 'the server has "' + current + '"' : 'no model is loaded') +
-            '. Set When A Different Model Is Loaded to "load" to swap it automatically');
+    if ((config.whenDifferent || 'load') === 'fail' && (!sameModel || differing.length > 0)) {
+        if (!sameModel) {
+            throw new Error('SD.cpp: this workflow expects "' + modelName + '" to be loaded, but ' +
+                (current ? 'the server has "' + current + '"' : 'no model is loaded') +
+                '. Set When A Different Model Is Loaded to "load" to swap it automatically');
+        }
+        throw new Error('SD.cpp: "' + modelName + '" is loaded, but not with the settings this ' +
+            'workflow needs - ' + differing.join('; ') +
+            '. Set When A Different Model Is Loaded to "load" to reload it');
+    }
+
+    if (sameModel && differing.length > 0) {
+        smartbotic.log.info('SD.cpp: reloading ' + modelName + ' because ' + differing.join('; '));
     }
 
     const credential = readCredential(config.credentialId);
@@ -350,8 +512,8 @@ async function execute(config, input, context) {
     putIfSet(body, 'llm', config.llm);
     putIfSet(body, 'taesd', config.taesd);
     putIfSet(body, 'controlnet', config.controlnet);
-    if (config.options && typeof config.options === 'object') {
-        body.options = config.options;
+    if (Object.keys(wanted).length > 0) {
+        body.options = wanted;
     }
 
     smartbotic.log.info('SD.cpp: loading ' + modelName +
@@ -376,6 +538,9 @@ async function execute(config, input, context) {
         architecture: after.model_architecture || '',
         loaded: true,
         alreadyLoaded: false,
+        // Empty when the model itself changed; otherwise the settings that
+        // forced a reload of a model that was already there.
+        reloadedFor: sameModel ? differing : [],
         previousModel: sameModel ? '' : current,
         loadedComponents: loaded.loaded_components || after.loaded_components || {},
         elapsedMs: Date.now() - startedAt

+ 19 - 0
tests/nodes/sdcpp-load-options-mismatch.json

@@ -0,0 +1,19 @@
+{
+  "name": "diag-load-options-mismatch",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "health", "name": "Health", "type": "sdcpp-health", "position": {"x": 0, "y": 100},
+     "config": {"serverUrl": "http://mulan:8077"}},
+    {"id": "load", "name": "Load", "type": "sdcpp-model-load", "position": {"x": 0, "y": 220},
+     "config": {"serverUrl": "http://mulan:8077",
+                "credentialId": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1",
+                "modelName": "{{$node[\"Health\"].modelName}}",
+                "streamLayers": false,
+                "whenDifferent": "fail"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "health", "targetInput": "data"},
+    {"sourceNodeId": "health", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "data"}
+  ],
+  "expect": {"load": {"status": "failed", "errorContains": "not with the settings"}}
+}

+ 89 - 16
tests/nodes/sdcpp-model-load-idempotent.json

@@ -1,25 +1,98 @@
 {
   "name": "verify-sdcpp-model-load-idempotent",
   "nodes": [
-    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
-    {"id": "srv", "name": "SD.cpp Server", "type": "configurator", "position": {"x": -260, "y": 100},
-     "config": {"label": "SD.cpp Server", "settings": [
-       {"name": "serverUrl", "value": "http://mulan:8077"},
-       {"name": "credentialId", "value": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1"}
-     ]}},
-    {"id": "health", "name": "Health", "type": "sdcpp-health", "position": {"x": 0, "y": 200},
-     "config": {}},
-    {"id": "load", "name": "Load", "type": "sdcpp-model-load", "position": {"x": 0, "y": 320},
-     "config": {"modelName": "{{$node[\"Health\"].modelName}}"}}
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "srv",
+      "name": "SD.cpp Server",
+      "type": "configurator",
+      "position": {
+        "x": -260,
+        "y": 100
+      },
+      "config": {
+        "label": "SD.cpp Server",
+        "settings": [
+          {
+            "name": "serverUrl",
+            "value": "http://mulan:8077"
+          },
+          {
+            "name": "credentialId",
+            "value": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1"
+          }
+        ]
+      }
+    },
+    {
+      "id": "health",
+      "name": "Health",
+      "type": "sdcpp-health",
+      "position": {
+        "x": 0,
+        "y": 200
+      },
+      "config": {}
+    },
+    {
+      "id": "load",
+      "name": "Load",
+      "type": "sdcpp-model-load",
+      "position": {
+        "x": 0,
+        "y": 320
+      },
+      "config": {
+        "modelName": "{{$node[\"Health\"].modelName}}",
+        "streamLayers": true,
+        "eagerLoad": true
+      }
+    }
   ],
   "connections": [
-    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "health", "targetInput": "data"},
-    {"sourceNodeId": "health", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "data"},
-    {"sourceNodeId": "srv", "sourceOutput": "main", "targetNodeId": "health", "targetInput": "config"},
-    {"sourceNodeId": "srv", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "config"}
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "health",
+      "targetInput": "data"
+    },
+    {
+      "sourceNodeId": "health",
+      "sourceOutput": "main",
+      "targetNodeId": "load",
+      "targetInput": "data"
+    },
+    {
+      "sourceNodeId": "srv",
+      "sourceOutput": "main",
+      "targetNodeId": "health",
+      "targetInput": "config"
+    },
+    {
+      "sourceNodeId": "srv",
+      "sourceOutput": "main",
+      "targetNodeId": "load",
+      "targetInput": "config"
+    }
   ],
   "expectStatus": "completed",
   "expect": {
-    "load": {"status": "completed", "output": {"alreadyLoaded": true, "loaded": false}}
+    "load": {
+      "status": "completed",
+      "output": {
+        "alreadyLoaded": true,
+        "loaded": false,
+        "reloadedFor": []
+      }
+    }
   }
-}
+}