fix: allow scalar modal map inputs

This commit is contained in:
Ken Simpson
2026-06-26 08:52:01 -07:00
parent ab59e04278
commit 4e24f740b6
5 changed files with 67 additions and 2 deletions
+1 -1
View File
@@ -327,7 +327,7 @@ Current mapped-execution rules:
- one `Modal Map Input` boundary per remote component
- downstream remote nodes reachable from that map marker stay in the same mapped remote component
- mapped inputs may currently be Python lists, `IMAGE` batches, `LATENT` batches, and other batched tensors split on dimension `0`
- mapped inputs may currently be scalar pass-through values, Python lists, `IMAGE` batches, `LATENT` batches, and other batched tensors split on dimension `0`
- if queue-time rewrite had to leave a mapped boundary as `io_type="*"`, the runtime still recovers obvious concrete cases such as `list` values of LATENT-like mappings or boundaries whose target sockets all declare the same concrete input type
- ordinary remote components without `Modal Map Input` still preserve ComfyUI's usual zipped batch semantics at the remote boundary: if multiple boundary inputs arrive as compatible batches, Modal-Sync fans that component out per item instead of injecting the raw list into primitive widget sockets like `seed: INT`
- that implicit batching path now partitions one-time and per-item execute nodes too, so if two remote samplers share the same upstream `MODEL` but only one sampler receives a multi-item batch input, the sibling sampler still runs once instead of repeating once per batch item
+1
View File
@@ -274,3 +274,4 @@
- Prompt-level Modal cancellation has to release both the remote work and the local prompt admission gate. If a user cancels during Modal deploy/readiness, ComfyUI can mark the prompt interrupted while the background Modal call still owns the local gate, leaving the next prompt stuck behind an already-cancelled prompt id.
- `ModalMapInput` does not remove the need for ordered split proxies. If a mapped remote component has a local re-entry dependency, such as an early sampler preview feeding a local text/image node that returns into a later mapped sampler, the static side must still split into ordered phases before the mapped phase or the rewritten prompt can create a false dependency cycle.
- Implicit mapped cancellation has to stop the local item scheduler, not only the remote payload that is currently running. If a sampler lane observes a prompt interrupt, queued sibling items and final cleanup payloads must be suppressed immediately or a quick cancel/restart can dispatch stale mapped sampler work after the UI already reports cancellation.
- `ModalMapInput` should accept scalar primitive values as a one-item map. This keeps the marker useful as a no-op adapter when an upstream control value like `INT` is not batched, while preserving fan-out behavior for lists, tensor batches, and `LATENT` mappings.
+4 -1
View File
@@ -218,6 +218,9 @@ def split_mapped_value(value: Any, io_type: str) -> list[Any]:
return list(value)
normalized_io_type = str(io_type)
if _is_scalar(value):
return [value]
if normalized_io_type == "LATENT" and isinstance(value, Mapping):
return _split_latent_batch(value)
@@ -234,7 +237,7 @@ def split_mapped_value(value: Any, io_type: str) -> list[Any]:
return _split_tensor_batch(value)
raise TypeError(
"Mapped execution only supports Python lists, tensor batches, and LATENT dictionaries. "
"Mapped execution only supports scalar values, Python lists, tensor batches, and LATENT dictionaries. "
f"Unsupported mapped value type {type(value)!r} for io_type={normalized_io_type!r}."
)
+54
View File
@@ -971,6 +971,60 @@ def test_modal_map_input_execute_boosts_exact_warmup_for_registered_context(
}
def test_modal_map_input_execute_accepts_scalar_for_single_item_warmup(
modal_executor_module: Any,
remote_modal_app_module: Any,
monkeypatch: Any,
) -> None:
"""A scalar Modal Map Input should pass through and warm one mapped item."""
observed_boost: dict[str, Any] = {}
def fake_boost_mapped_component_warmup(
payload: dict[str, Any],
*,
total_items: int,
reason: str,
) -> tuple[int, int]:
"""Record the scalar warmup boost request without touching Modal."""
observed_boost["payload"] = dict(payload)
observed_boost["total_items"] = total_items
observed_boost["reason"] = reason
return 1, 1
monkeypatch.setattr(
remote_modal_app_module,
"boost_mapped_component_warmup",
fake_boost_mapped_component_warmup,
)
with modal_executor_module._MODAL_MAP_WARMUP_CONTEXTS_LOCK:
modal_executor_module._MODAL_MAP_WARMUP_CONTEXTS.clear()
modal_executor_module.register_modal_map_input_warmup_context(
"map-node-1",
{
"prompt_id": "prompt-1",
"component_id": "mapped-component-1",
"extra_data": {"modal": {"mapped_component_ids": ["mapped-component-1"]}},
},
"INT",
)
result = modal_executor_module.ModalMapInput.execute(
value=7,
unique_id="map-node-1",
)
assert result.result == (7,)
assert observed_boost == {
"payload": {
"prompt_id": "prompt-1",
"component_id": "mapped-component-1",
"extra_data": {"modal": {"mapped_component_ids": ["mapped-component-1"]}},
},
"total_items": 1,
"reason": "modal_map_input_execute",
}
def test_local_remote_app_executes_original_node(
remote_modal_app_module: Any,
serialization_module: Any,
+7
View File
@@ -106,6 +106,13 @@ def test_split_mapped_value_accepts_python_lists(serialization_module: Any) -> N
assert items == ["a", "b", "c"]
def test_split_mapped_value_treats_scalar_as_single_item(serialization_module: Any) -> None:
"""Mapped execution should treat scalar marker inputs as a one-item map."""
items = serialization_module.split_mapped_value(7, "INT")
assert items == [7]
def test_split_and_join_tensor_batch_for_mapped_execution(serialization_module: Any) -> None:
"""Mapped execution should split and reassemble tensor batches on the leading dimension."""
torch = pytest.importorskip("torch")