feat: the model loader shows each SLA field only in the mode it belongs to (sparsity for sla/vsa, tau for sol-attn, block size and stabilize for Triton, tail for comfy_kitchen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-10 01:38:45 +03:00
co-authored by Claude Opus 5.5
parent 8eaf5be5f4
commit fc92e5869b
6 changed files with 59 additions and 23 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ export const models = (app) => function mount() {
if (JSON.stringify((live.inputs || {})[name]) === JSON.stringify(value)) return; // a blur that changed nothing saves nothing
await ps.save({ inputs: { [slot.id]: { [name]: value } } });
const spec = specs[slot.node];
if (spec && (spec.widgets || []).some((w) => w.shows && w.shows.input === name)) { // a choice that brings fields: what they show is what runs
if (spec && (spec.widgets || []).some((w) => (w.shows || []).some((c) => c.input === name))) { // a choice that brings fields: what they show is what runs
const now = slotsNow().find((x) => x.id === slot.id);
const fill = now && shownValues(now, spec);
if (fill && Object.keys(fill).length) await ps.save({ inputs: { [slot.id]: fill } });
+9 -9
View File
@@ -19,18 +19,18 @@ export function sourcesFor(slot, type, slots, specs) {
}
// The dropdowns' own values as the form would fill them, so a new node's revealed fields follow its default choice.
const shownParents = (slot, spec) => Object.fromEntries(((spec && spec.widgets) || []).filter((w) => w.type === "COMBO" && !(slot.inputs && slot.inputs[w.name] !== undefined))
.map((w) => [w.name, w.default !== undefined ? w.default : (w.choices || [])[0]]));
const shownParents = (slot, spec) => Object.fromEntries(((spec && spec.widgets) || []).filter((w) => !(slot.inputs && slot.inputs[w.name] !== undefined))
.map((w) => [w.name, w.default !== undefined ? w.default : w.type === "BOOLEAN" ? false : (w.choices || [])[0]]));
/** A node that is new, or whose input was just unwired, has nothing SET on it while its form shows defaults:
* these are the values to send so that what runs is what is seen. */
/** Whether a field a dynamic dropdown's choice brings is showing: its dropdown (as set, else its default) is on one of its choices. */
export const showing = (w, slot, spec) => {
if (!w.shows) return true;
const parent = ((spec && spec.widgets) || []).find((x) => x.name === w.shows.input);
const picked = slot.inputs && slot.inputs[w.shows.input] !== undefined ? slot.inputs[w.shows.input] : parent && (parent.default !== undefined ? parent.default : (parent.choices || [])[0]);
return w.shows.when.includes(picked);
};
/** Whether a field is drawn: every input its `shows` names ([{input, when}]: a dynamic dropdown's choice, or a
* node's own rule) holds one of the listed values -- as set, else as the form shows it by default. */
export const showing = (w, slot, spec) => (w.shows || []).every(({ input, when }) => {
const parent = ((spec && spec.widgets) || []).find((x) => x.name === input);
const picked = slot.inputs && slot.inputs[input] !== undefined ? slot.inputs[input] : parent && (parent.default !== undefined ? parent.default : parent.type === "BOOLEAN" ? false : (parent.choices || [])[0]);
return when.includes(picked);
});
export function shownValues(slot, spec, onlyInput) {
const out = {};
+15
View File
@@ -21,3 +21,18 @@ test("what the form shows is sent for a slot with nothing set, and nothing alrea
assert.deepEqual(shownValues({ id: "c", inputs: { steps: 4 } }, specs.C), { mode: "x", on: false });
assert.deepEqual(shownValues({ id: "c", inputs: {} }, specs.C, "steps"), { steps: 8 });
});
test("a field shows only in the mode it belongs to: every rule it names must hold, defaults count", async () => {
const { showing, shownValues } = await import("./wiring.js");
const spec = { widgets: [
{ name: "sla", type: "BOOLEAN", default: false },
{ name: "sla_method", type: "COMBO", choices: ["sla", "sol-attn", "vsa"], default: "sla", shows: [{ input: "sla", when: [true] }] },
{ name: "sla_sparsity", type: "FLOAT", default: 0.9, shows: [{ input: "sla", when: [true] }, { input: "sla_method", when: ["sla", "vsa"] }] },
{ name: "sla_tau", type: "FLOAT", default: 1.3, shows: [{ input: "sla", when: [true] }, { input: "sla_method", when: ["sol-attn"] }] },
] };
const drawn = (inputs) => spec.widgets.filter((w) => showing(w, { inputs }, spec)).map((w) => w.name);
assert.deepEqual(drawn({}), ["sla"]);
assert.deepEqual(drawn({ sla: true }), ["sla", "sla_method", "sla_sparsity"]);
assert.deepEqual(drawn({ sla: true, sla_method: "sol-attn" }), ["sla", "sla_method", "sla_tau"]);
assert.deepEqual(shownValues({ inputs: { sla: true, sla_method: "sol-attn" } }, spec), { sla_tau: 1.3 });
});
+4 -1
View File
@@ -53,6 +53,9 @@ def _one(name: str, declared: Any, required: bool) -> Dict[str, Any]:
for key in KEPT:
if key in options:
widget[key] = options[key]
# [{input, when}]: the form draws this only while every named input holds one of its values.
if isinstance(options.get("funpack_shows"), list):
widget["shows"] = options["funpack_shows"]
return widget
@@ -96,7 +99,7 @@ def describe(class_type: str) -> Optional[dict]:
kind, options = comfy_types.declared(declared)
if comfy_types.is_combo(kind) and comfy_types.reveals(options):
for dotted, decl, req, parent, keys in comfy_types.revealed(name, options):
sub = dict(_one(dotted, decl, req), shows={"input": parent, "when": keys})
sub = dict(_one(dotted, decl, req), shows=[{"input": parent, "when": keys}])
(widgets if sub["type"] in PRIMITIVE and not sub.get("forceInput") else sockets).append(sub)
outputs = list(getattr(node, "RETURN_TYPES", ()) or ())
+18 -12
View File
@@ -31,6 +31,12 @@ def _model_file_choices():
gguf_support.gguf_names("diffusion_models")
def shows(*conditions):
"""Draw an input only while each (other input, values) holds: SLA's fields belong to one method
or engine each, and a field that does nothing in the mode picked is noise. Read by core/widgets."""
return {"funpack_shows": [{"input": name, "when": list(values)} for name, values in conditions]}
class FunPackDiffusionModelLoader(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
@@ -64,14 +70,14 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
"that is not MiniMax H3 or on a machine without Triton."),
# SLA settings. Every one is validated at its default (see sla_attention);
# they are here because a knob whose value was measured is still a knob.
io.Float.Input("sla_sparsity", default=sla_attention.SLA_DEFAULTS["sparsity_ratio"],
io.Float.Input("sla_sparsity", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "vsa"])), default=sla_attention.SLA_DEFAULTS["sparsity_ratio"],
min=0.0, max=0.95, step=0.05, optional=True,
tooltip="Fraction of key blocks skipped, when attention is "
"sla_h3. 0.90 is validated; 0.85 is lightx2v's own value "
"and ~15% slower. Break-even is around 0.60 -- below that "
"the kernel is SLOWER than dense, so a low value is a "
"loss, not a safe fallback."),
io.Combo.Input("sla_block_size",
io.Combo.Input("sla_block_size", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"]), ("sla_engine", ["triton"])),
options=["32", "64", "128"],
default=str(sla_attention.SLA_DEFAULTS["block_size"]),
optional=True,
@@ -81,21 +87,21 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
"the same rows are 3% of a video frame. Use 128 only "
"when the audio does not matter; 32 routes finer still for "
"about the same time. Triton engine only."),
io.Boolean.Input("sla_protect_audio",
io.Boolean.Input("sla_protect_audio", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])),
default=sla_attention.SLA_DEFAULTS["protect_audio"], optional=True,
tooltip="Always attend the prompt's words and every audio "
"stream, whatever top-k picks. Audio is ~1% of the packed "
"sequence, so plain top-k regularly drops all of it "
"and the soundtrack degrades while the video still "
"looks fine. Reference images stay sparse: see sla_references."),
io.Int.Input("sla_min_seq_len",
io.Int.Input("sla_min_seq_len", extra_dict=shows(("sla", [True])),
default=sla_attention.SLA_DEFAULTS["min_seq_len"],
min=0, max=1000000, step=1024, optional=True,
tooltip="Sequences shorter than this stay dense. Guards the short "
"text refiner, which must never be sparsified, and "
"low-resolution runs where block selection costs more "
"than it saves."),
io.Int.Input("sla_dense_last_steps",
io.Int.Input("sla_dense_last_steps", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])),
default=sla_attention.SLA_DEFAULTS["dense_last_steps"],
min=0, max=8, optional=True,
tooltip="Run the last N sampling steps at full attention. 0 "
@@ -112,38 +118,38 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
"always holds; the speed-up is lost on layers a LoRA is applied "
"to and when compute dtype is forced."),
# Added later, so after everything: saved workflows can store widget values by position.
io.Combo.Input("sla_engine", options=list(sla_attention.ENGINES),
io.Combo.Input("sla_engine", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"])), options=list(sla_attention.ENGINES),
default=sla_attention.SLA_DEFAULTS["engine"], optional=True,
tooltip="What runs the sparse calls. comfy_kitchen: ComfyUI's compiled "
"int8 sol_attn (needs comfy-kitchen 0.2.32+; the Triton kernel "
"runs instead where it is missing, and the status says so). "
"triton: FunPack's own kernel, the only one with block size and "
"stabilize motion."),
io.String.Input("sla_dense_steps", default=sla_attention.SLA_DEFAULTS["dense_steps"],
io.String.Input("sla_dense_steps", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])), default=sla_attention.SLA_DEFAULTS["dense_steps"],
optional=True,
tooltip="Steps at full attention, counted from 0: '0' or '0,1' or "
"'0-2'. The first steps set the layout and how closely the "
"prompt is followed, so the first one is kept exact by "
"default. Blank: none. Adds to dense last steps. Not used by vsa."),
io.Combo.Input("sla_references", options=list(sla_attention.REFERENCES),
io.Combo.Input("sla_references", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn"])), options=list(sla_attention.REFERENCES),
default=sla_attention.SLA_DEFAULTS["references"], optional=True,
tooltip="Reference images, conditioning frames and the prompt's vision "
"tokens. off: sparse like the video. light: each keeps its best "
"15% for every query block. heavy: all of them exact, slow with "
"big references. On comfy_kitchen, light picks one 15% for all "
"query blocks."),
io.Boolean.Input("sla_tail", default=sla_attention.SLA_DEFAULTS["tail"], optional=True,
io.Boolean.Input("sla_tail", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"]), ("sla_engine", ["comfy_kitchen"])), default=sla_attention.SLA_DEFAULTS["tail"], optional=True,
tooltip="comfy_kitchen engine: the skipped blocks still count, as one "
"averaged term per query block, instead of not at all. Off "
"matches what the SLA turbo LoRA was trained against; untested "
"on H3."),
io.Boolean.Input("sla_stabilize_motion",
io.Boolean.Input("sla_stabilize_motion", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"]), ("sla_engine", ["triton"])),
default=sla_attention.SLA_DEFAULTS["stabilize_motion"], optional=True,
tooltip="Triton engine: each layer leans toward the blocks it picked last "
"step, so near ties stop flipping and showing as a faint double "
"exposure on fast motion. A fix for that one symptom; a little "
"more memory."),
io.Combo.Input("sla_method", options=list(sla_attention.METHODS),
io.Combo.Input("sla_method", extra_dict=shows(("sla", [True])), options=list(sla_attention.METHODS),
default=sla_attention.SLA_DEFAULTS["method"], optional=True,
tooltip="How the blocks worth attending are chosen. sla: a fixed share "
"(sla_sparsity), what SLA turbo LoRAs were trained against. "
@@ -152,7 +158,7 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
"for it (the status says when the model lacks VSA's layers); run "
"by ComfyUI's own Model Sparse Attention, keeping 1 - sla_sparsity "
"of the cubes."),
io.Float.Input("sla_tau", default=sla_attention.SLA_DEFAULTS["tau"], min=0.0, max=4.0,
io.Float.Input("sla_tau", extra_dict=shows(("sla", [True]), ("sla_method", ["sol-attn"])), default=sla_attention.SLA_DEFAULTS["tau"], min=0.0, max=4.0,
step=0.05, optional=True,
tooltip="sol-attn only: higher skips more. About 1.0 keeps ~16% of key "
"blocks exact, 1.5 ~7%, 2.0 ~3%."),
@@ -490,3 +490,15 @@ def test_sol_attn_routes_by_threshold_and_vsa_hands_over_to_comfyui(monkeypatch)
assert installed and got["vsa"] is True and abs(got["topk_ratio"] - 0.1) < 1e-9
assert got["override"] is backend # dense calls still go to the chosen backend
assert "no VSA layers" in note
def test_the_loader_draws_each_sla_field_only_in_its_mode():
"""'All values are fucked up': every SLA field was drawn whatever the method, sparsity beside
sol-attn's own threshold."""
from modules.loaders.diffusion_model.nodes import FunPackDiffusionModelLoader as L
rules = {i.id: {c["input"]: c["when"] for c in (i.extra_dict or {}).get("funpack_shows", [])}
for i in L.define_schema().inputs}
assert rules["sla_sparsity"]["sla_method"] == ["sla", "vsa"] and rules["sla_tau"]["sla_method"] == ["sol-attn"]
assert rules["sla_block_size"]["sla_engine"] == ["triton"] and rules["sla_tail"]["sla_engine"] == ["comfy_kitchen"]
assert all(r.get("sla") == [True] for name, r in rules.items() if name.startswith("sla_"))
assert rules["sla"] == {} and rules["int8_convrot"] == {}