DEV Community

Cover image for Running Multiple ComfyUI Instances in Parallel on a Single GPU — What Actually Breaks First
Abdollah Ebadi
Abdollah Ebadi

Posted on

Running Multiple ComfyUI Instances in Parallel on a Single GPU — What Actually Breaks First

The Problem Nobody Has Measured

If you have spent any time scaling ComfyUI beyond a single user, you have almost certainly hit the queue wall. ComfyUI processes one job at a time. Submit 30 requests and the 30th waits for the previous 29 to finish — regardless of how much VRAM you have sitting idle.

The community has been asking for native parallel execution for years. The GitHub discussions tell the story clearly: one developer running three ComfyUI servers on a 48GB GPU reported that "while this reduced generation times, I encountered GPU overloading" without being able to explain why. Another thread notes that ComfyUI "currently provides no method to execute workflows in parallel" and suggests modifying the KSampler as the only path forward. SwarmUI can manage multiple ComfyUI instances as backends, but it adds a full UI layer and doesn't answer the fundamental question: how many instances can a single GPU actually handle before performance degrades?

Nobody had published empirical data on this. So I ran the experiment myself.

My use case was building an automated image generation pipeline that needed to maximise throughput on a single cloud GPU — specifically an RTX 5090 running FLUX.2 Klein 4B. Before spending money on cloud GPU time, I wanted to validate the architecture locally and understand exactly where the limits were. What I found was counterintuitive: VRAM capacity is almost never the bottleneck. The GPU's compute bus is.


The Setup

All tests ran on an NVIDIA RTX 3080 Laptop (16GB VRAM), Linux Mint 22, ComfyUI 0.24.0, PyTorch 2.6.0+cu124. The test model was SD 1.5 (v1-5-pruned-emaonly.safetensors) — small enough to run multiple instances on a 16GB card while being representative of the dispatch and parallelism patterns I needed to validate. The production model this was ultimately for is FLUX.2 Klein 4B, but the architectural findings transfer directly.

The workflow was a minimal txt2img pipeline: checkpoint loader → CLIP encode (positive and negative) → KSampler at 20 steps, 512×512 → VAE decode → save image.

{
  "1": {
    "inputs": { "ckpt_name": "v1-5-pruned-emaonly.safetensors" },
    "class_type": "CheckpointLoaderSimple",
    "_meta": { "title": "Load Checkpoint" }
  },
  "2": {
    "inputs": {
      "text": "a beautiful coastal watercolour scrapbook page, soft pastel tones, painterly style",
      "clip": ["1", 1]
    },
    "class_type": "CLIPTextEncode",
    "_meta": { "title": "Positive Prompt" }
  },
  "3": {
    "inputs": {
      "text": "ugly, blurry, low quality",
      "clip": ["1", 1]
    },
    "class_type": "CLIPTextEncode",
    "_meta": { "title": "Negative Prompt" }
  },
  "4": {
    "inputs": { "width": 512, "height": 512, "batch_size": 1 },
    "class_type": "EmptyLatentImage",
    "_meta": { "title": "Empty Latent" }
  },
  "5": {
    "inputs": {
      "seed": 42,
      "steps": 20,
      "cfg": 7.0,
      "sampler_name": "euler",
      "scheduler": "normal",
      "denoise": 1.0,
      "model": ["1", 0],
      "positive": ["2", 0],
      "negative": ["3", 0],
      "latent_image": ["4", 0]
    },
    "class_type": "KSampler",
    "_meta": { "title": "KSampler" }
  },
  "6": {
    "inputs": { "samples": ["5", 0], "vae": ["1", 2] },
    "class_type": "VAEDecode",
    "_meta": { "title": "VAE Decode" }
  },
  "7": {
    "inputs": { "filename_prefix": "test", "images": ["6", 0] },
    "class_type": "SaveImage",
    "_meta": { "title": "Save Image" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Running Multiple ComfyUI Instances

ComfyUI has no built-in multi-instance mode, but running N independent processes on separate ports is straightforward. Each instance is a completely independent Python process with its own queue, VRAM allocation, and execution state. The only non-obvious gotcha is SQLite.

ComfyUI uses SQLite for internal state — workflow history, user settings, queue tracking. SQLite applies a file-level write lock, so two instances pointing at the same database file will fail immediately with a lock conflict error. The fix is the --database-url flag, which lets you give each instance its own isolated database at any writable path:

# Instance 1 — primary, uses default database location
python main.py --port 8188

# Instance 2 — separate database to avoid SQLite lock conflict
python main.py --port 8189 --database-url sqlite:////tmp/comfyui_8189.db

# Instance 3, 4, 5 — same pattern, unique path per instance
python main.py --port 8190 --database-url sqlite:////tmp/comfyui_8190.db
python main.py --port 8191 --database-url sqlite:////tmp/comfyui_8191.db
python main.py --port 8192 --database-url sqlite:////tmp/comfyui_8192.db
Enter fullscreen mode Exit fullscreen mode

ComfyUI initialises a fresh database schema at whatever path you give it on first run, so any writable path works. In production on a persistent cloud instance, use a stable path like /workspace/comfyui_8189.db rather than /tmp/ to preserve job history across restarts.

On the memory flag question: --lowvram tells ComfyUI to aggressively offload model weights between VRAM and system RAM during generation. On cards with sufficient VRAM to hold the full model in memory, this flag adds unnecessary PCIe transfer overhead. Use no memory flag on large-VRAM cards and let ComfyUI manage memory in its default mode.


The Dispatcher

Rather than manually managing each instance, I wrote a small async dispatcher in Python. The design goals were deliberately minimal:

  • Instance-count agnostic — initialise with any list of ports, same code whether you have 2 or 10 instances
  • Truly parallel — all jobs fire simultaneously via asyncio.gather, not sequentially
  • I/O-bound async — dispatching and polling are pure network I/O, making asyncio the correct choice over threading
  • Health-check first — confirms all instances are reachable before any jobs are dispatched
  • Output path returned — result JSON includes the generated filename for downstream pipeline use
import asyncio
import aiohttp
import json
import time
import copy
from pathlib import Path
from typing import List, Dict


class ComfyUIInstance:
    """
    Represents a single ComfyUI process running on a given port.
    Each instance is fully independent — its own queue, VRAM allocation,
    and SQLite database. Communication is via HTTP to the ComfyUI REST API.
    """

    def __init__(self, port: int, host: str = "127.0.0.1"):
        self.port = port
        self.host = host
        self.base_url = f"http://{host}:{port}"
        self.busy = False  # Tracks whether this instance has an active job

    async def is_alive(self, session: aiohttp.ClientSession) -> bool:
        """
        Health check via /system_stats endpoint.
        Returns True if the instance is reachable and responding.
        Uses a short timeout so unhealthy instances don't block dispatch.
        """
        try:
            async with session.get(
                f"{self.base_url}/system_stats",
                timeout=aiohttp.ClientTimeout(total=3)
            ) as r:
                return r.status == 200
        except Exception:
            return False

    async def submit(self, session: aiohttp.ClientSession, workflow: Dict) -> str | None:
        """
        POST a workflow to ComfyUI's /prompt endpoint.
        ComfyUI accepts the full workflow graph as JSON under the "prompt" key.
        Returns the prompt_id assigned by ComfyUI, which is used for polling.
        Returns None if the submission fails.
        """
        payload = {"prompt": workflow}
        try:
            async with session.post(f"{self.base_url}/prompt", json=payload) as r:
                if r.status == 200:
                    data = await r.json()
                    return data.get("prompt_id")
                else:
                    text = await r.text()
                    print(f"[:{self.port}] Submit failed {r.status}: {text[:200]}")
                    return None
        except Exception as e:
            print(f"[:{self.port}] Submit error: {e}")
            return None

    async def poll_until_done(
        self,
        session: aiohttp.ClientSession,
        prompt_id: str,
        poll_interval: float = 1.0
    ) -> Dict | None:
        """
        Poll /history/{prompt_id} until ComfyUI reports the job is complete.
        ComfyUI adds the prompt_id key to the history response only when
        execution is finished — absent means still running.
        poll_interval controls how often we check (default 1s).
        """
        url = f"{self.base_url}/history/{prompt_id}"
        while True:
            try:
                async with session.get(url) as r:
                    if r.status == 200:
                        data = await r.json()
                        # Key present in response = job complete
                        if prompt_id in data:
                            return data[prompt_id]
            except Exception as e:
                print(f"[:{self.port}] Poll error: {e}")
            await asyncio.sleep(poll_interval)


class ComfyUIDispatcher:
    """
    Dispatches image generation jobs across N ComfyUI instances in parallel.

    Design: instance-count agnostic. Pass any number of ports at init.
    Assignment uses round-robin: job 1 → instance 0, job 2 → instance 1,
    job N → instance N % len(instances). All jobs fire simultaneously via
    asyncio.gather — wall clock time equals single-job time, not N × job time.

    This is an I/O-bound dispatcher (HTTP requests + polling), so asyncio
    is the correct concurrency model. The heavy compute happens inside each
    ComfyUI process, not here.
    """

    def __init__(self, ports: List[int], host: str = "127.0.0.1"):
        self.instances = [ComfyUIInstance(port=p, host=host) for p in ports]
        print(f"[Dispatcher] Initialised with {len(self.instances)} instances: ports {ports}")

    async def _run_job(
        self,
        session: aiohttp.ClientSession,
        instance: ComfyUIInstance,
        workflow: Dict,
        job_id: int
    ) -> Dict:
        """
        Submit a single job to an instance and wait for completion.
        Marks the instance busy during execution and idle when done.
        Returns a result dict containing job metadata and output file paths.
        """
        instance.busy = True
        start = time.time()
        print(f"[Job {job_id}] → port {instance.port} | submitted at t=0.0s")

        prompt_id = await instance.submit(session, workflow)
        if not prompt_id:
            instance.busy = False
            return {
                "job_id": job_id,
                "port": instance.port,
                "status": "failed",
                "error": "submit failed"
            }

        print(f"[Job {job_id}] prompt_id={prompt_id} | polling...")
        result = await instance.poll_until_done(session, prompt_id)

        elapsed = time.time() - start
        instance.busy = False

        if result:
            outputs = result.get("outputs", {})
            print(f"[Job {job_id}] ✅ done in {elapsed:.1f}s on port {instance.port}")
            return {
                "job_id": job_id,
                "port": instance.port,
                "prompt_id": prompt_id,
                "status": "done",
                "elapsed": elapsed,
                "outputs": outputs   # Contains output filenames for downstream use
            }
        else:
            print(f"[Job {job_id}] ❌ failed after {elapsed:.1f}s")
            return {
                "job_id": job_id,
                "port": instance.port,
                "status": "failed",
                "elapsed": elapsed
            }

    async def run_parallel(
        self,
        workflow: Dict,
        num_jobs: int = None,
        seed_offset: bool = True
    ) -> List[Dict]:
        """
        Fire num_jobs jobs across all available instances simultaneously.

        num_jobs defaults to len(instances) — one job per instance.
        seed_offset=True increments the seed per job so each instance
        generates a different image despite receiving the same prompt.

        asyncio.gather fires all coroutines concurrently — they all start
        at t=0 and run in parallel. Wall clock = single job time, not N × job time.
        """
        if num_jobs is None:
            num_jobs = len(self.instances)

        async with aiohttp.ClientSession() as session:
            # Health check before dispatching — skip unreachable instances
            print(f"\n[Dispatcher] Checking instance health...")
            available = []
            for inst in self.instances:
                ok = await inst.is_alive(session)
                status = "✅ alive" if ok else "❌ unreachable"
                print(f"[Dispatcher] Port {inst.port}: {status}")
                if ok:
                    available.append(inst)

            if not available:
                print("[Dispatcher] No instances reachable. Aborting.")
                return []

            print(f"[Dispatcher] {len(available)} instance(s) available for {num_jobs} job(s)\n")

            # Build one workflow copy per job with a unique seed
            # Unique seeds prevent ComfyUI from serving cached results
            # and ensure each instance generates a distinct image
            tasks = []
            for i in range(num_jobs):
                wf = copy.deepcopy(workflow)  # Deep copy — never mutate the original

                # SD 1.5 workflow seed node (node "5", KSampler)
                if seed_offset and "5" in wf and "seed" in wf["5"].get("inputs", {}):
                    wf["5"]["inputs"]["seed"] = 42 + i * 1000

                # FLUX.2 workflow seed node ("75:73", RandomNoise)
                if seed_offset and "75:73" in wf:
                    wf["75:73"]["inputs"]["noise_seed"] = 42 + i * 1000

                # Round-robin assignment: job i → instance i % len(available)
                instance = available[i % len(available)]
                tasks.append(self._run_job(session, instance, wf, i + 1))

            # Fire all jobs simultaneously — this is the key line
            # asyncio.gather schedules all coroutines concurrently on the event loop
            wall_start = time.time()
            print(f"[Dispatcher] Firing {len(tasks)} job(s) in parallel...\n")
            results = await asyncio.gather(*tasks)

            wall_elapsed = time.time() - wall_start
            print(f"\n[Dispatcher] All jobs complete in {wall_elapsed:.1f}s wall clock")
            print(f"[Dispatcher] Summary:")
            for r in results:
                print(
                    f"  Job {r['job_id']} → port {r['port']} | "
                    f"{r['status']} | {r.get('elapsed', 0):.1f}s"
                )

            return results


async def main():
    # Load the workflow JSON from disk
    workflow_path = Path("sd15_test_workflow.json")
    with workflow_path.open() as f:
        workflow = json.load(f)

    # Initialise dispatcher — add or remove ports to change instance count
    dispatcher = ComfyUIDispatcher(ports=[8188, 8189])

    # Fire one job per instance in parallel
    results = await dispatcher.run_parallel(workflow, num_jobs=2)

    print(f"\n[Test] Results: {json.dumps(results, indent=2, default=str)}")


if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Usage is straightforward — change the ports list to match however many instances you have running, and num_jobs to control how many jobs fire:

# Two instances, two parallel jobs
dispatcher = ComfyUIDispatcher(ports=[8188, 8189])
results = asyncio.run(dispatcher.run_parallel(workflow, num_jobs=2))

# Three instances, three parallel jobs
dispatcher = ComfyUIDispatcher(ports=[8188, 8189, 8190])
results = asyncio.run(dispatcher.run_parallel(workflow, num_jobs=3))

# Access output filenames from results
for r in results:
    images = r["outputs"].get("7", {}).get("images", [])
    for img in images:
        print(img["filename"])  # e.g. "test_00001_.png"
Enter fullscreen mode Exit fullscreen mode

VRAM Footprint Measurements

Before running parallel jobs I measured the actual VRAM footprint at each stage using nvidia-smi:

State VRAM used
Display only, no ComfyUI 71 MiB
Single ComfyUI instance at idle 239 MiB
Two instances at idle 405 MiB
Single instance peak during generation (SD 1.5) ~2,100 MiB
Two instances peak during parallel generation ~4,197 MiB
Five instances peak during parallel generation ~10,380 MiB

Two observations stand out. First, idle overhead per additional instance is negligible — the second instance added only 166 MiB at idle. Second, peak VRAM during generation scales roughly linearly at ~2,100 MiB per instance for SD 1.5. With five instances running simultaneously I was using only 10,380 MiB of a 16,384 MiB card — comfortably within limits with 6GB to spare.

At this point the obvious conclusion seemed to be: more instances, more throughput. The VRAM headroom was there. It just needed validating with actual timing numbers.


The Bus Contention Finding

Here are the wall clock results across all configurations tested, all on warm runs where the model was already loaded:

Config Instances Memory flag Wall clock Per-job time
Baseline 2 --lowvram 7.0s 7.0s
Scale up 5 --lowvram 11.0s 11.0s
No memory flag 5 none 10.41s 10.41s
Back to 2 2 none 5.0s 5.0s

Every run confirmed true parallelism — all jobs fired at t=0.0s simultaneously and wall clock matched per-job time, not a multiple of it. But the per-job time itself degraded significantly as instance count increased.

Going from 2 to 5 instances caused per-job time to climb from 7s to 11s — a 57% slowdown per job. Total throughput improved slightly (from 0.286 jobs/s to 0.454 jobs/s) but with sharply diminishing returns. And dropping back to 2 instances with no memory flag gave the fastest result of all: 5.0s per job.

The VRAM headroom was never the constraint. The bottleneck is the shared CUDA execution engine and PCIe memory bus.

With --lowvram, every instance continuously swaps tensor layers between system RAM and VRAM during generation via the PCIe bus. Five instances doing this simultaneously saturates the available PCIe bandwidth — on a laptop RTX 3080 this is PCIe 4.0 x8, already a constrained bus. Even without --lowvram, five processes competing for the same CUDA compute units creates scheduler contention that degrades individual job throughput.

More instances means more contention on a shared hardware resource that has nothing to do with VRAM capacity. The throughput gain is real but diminishing, and past 2 instances the per-job slowdown outweighs the parallelism benefit.


The Cold Run Penalty

One additional finding worth noting: the first job submitted to each instance after startup is consistently 2–3× slower than subsequent warm runs. This is model loading overhead — the checkpoint isn't resident in VRAM until the first inference call triggers loading.

In the 5-instance cold run this was clearly visible. Instances on ports 8188 and 8189 had handled previous jobs and completed in 5s. The three new instances on 8190–8192 took 12–13s each — the cold load penalty absorbed on their first real job.

[Job 1] ✅ done in 5.0s on port 8188   ← warm
[Job 2] ✅ done in 5.0s on port 8189   ← warm
[Job 3] ✅ done in 13.0s on port 8190  ← cold
[Job 4] ✅ done in 13.0s on port 8191  ← cold
[Job 5] ✅ done in 12.0s on port 8192  ← cold
Enter fullscreen mode Exit fullscreen mode

Production implication: send a dummy warmup job to each instance immediately after startup, before the real queue begins. This front-loads the loading penalty once, rather than hitting it on the first production job.

# Warmup both instances after startup
curl -X POST http://127.0.0.1:8188/prompt \
     -H "Content-Type: application/json" \
     -d "{\"prompt\": $(cat warmup_workflow.json)}"

curl -X POST http://127.0.0.1:8189/prompt \
     -H "Content-Type: application/json" \
     -d "{\"prompt\": $(cat warmup_workflow.json)}"
Enter fullscreen mode Exit fullscreen mode

Applying This to the Production Target

This experiment was aimed at sizing a deployment on an RTX 5090 (32GB VRAM) running FLUX.2 Klein 4B — a 4B parameter diffusion model with a real loaded footprint of ~10.7GB per instance.

VRAM analysis for two instances on the 5090:

Metric Value
Per-instance idle (FLUX.2 Klein loaded) ~10.7 GB
Per-instance peak during generation ~12–13 GB (estimated)
Two instances combined peak ~24–26 GB
Buffer remaining on 5090 32GB ~6–8 GB ✅

Two instances fit comfortably on VRAM. And the bus contention finding confirms that two is also the throughput sweet spot — a third instance would fit in VRAM but would degrade per-job time enough to erode the throughput gain.

The recommended startup config for cloud deployment:

# No memory flags on a large-VRAM card — default mode is optimal
# --lowvram adds unnecessary PCIe overhead when models fit in VRAM natively
python main.py --port 8188 &
python main.py --port 8189 \
    --database-url sqlite:////workspace/comfyui_8189.db &

sleep 15  # Wait for both instances to fully initialise

# Warmup — load models into VRAM before production queue starts
curl -X POST http://127.0.0.1:8188/prompt \
     -H "Content-Type: application/json" \
     -d "{\"prompt\": $(cat warmup_workflow.json)}"
curl -X POST http://127.0.0.1:8189/prompt \
     -H "Content-Type: application/json" \
     -d "{\"prompt\": $(cat warmup_workflow.json)}"
Enter fullscreen mode Exit fullscreen mode

Discussion: Does This Extend to Other Platforms?

The short answer is yes — because the bottleneck is in the hardware, not in ComfyUI.

The underlying constraint

The CUDA execution engine on any single GPU is a shared resource. Every CUDA process running on the same device — regardless of which framework spawned it — competes for the same compute units, the same memory controllers, and the same PCIe bus for host-device transfers. This is a property of the GPU architecture, not of ComfyUI's implementation.

LLM inference frameworks

vLLM, Ollama, and TGI handle multi-instance serving differently from ComfyUI — they're designed as inference servers that batch requests internally rather than running as parallel processes. But if you run two separate vLLM or Ollama processes on the same GPU simultaneously, you will hit exactly the same bus contention ceiling. The 2-instance sweet spot observed here is likely to hold for any pair of inference processes sharing a single physical GPU, regardless of model type.

The difference is that vLLM and TGI are optimised to saturate a single GPU efficiently through continuous batching — meaning you typically don't need multiple processes on the same card. For diffusion models there is no equivalent batching optimisation in ComfyUI, which is why multi-instance is the only path to parallelism.

Where this doesn't apply

  • NVLink multi-GPU setups — NVLink provides direct GPU-to-GPU interconnect at hundreds of GB/s, bypassing PCIe entirely. Bus contention between GPUs connected via NVLink is a fundamentally different problem.
  • Tensor parallelism — frameworks like vLLM with --tensor-parallel-size split a single model across multiple GPUs at the layer level, which is architectural parallelism rather than process-level concurrency.
  • Different GPU architectures — the exact sweet spot will vary by card. A workstation GPU with PCIe 5.0 x16 has significantly more bus bandwidth than a laptop GPU on PCIe 4.0 x8. The pattern holds but the numbers will differ.

The practical takeaway for diffusion pipelines

Until ComfyUI adds native parallel execution (which the maintainers have confirmed is not currently on the roadmap), multi-instance dispatch is the only production-viable path to throughput scaling on a single GPU. The approach in this post works — but measure your own sweet spot rather than assuming more instances is always better. The optimal instance count is determined by your GPU's bus bandwidth and compute scheduler capacity, not by VRAM.

For most consumer and prosumer GPUs in the RTX 4000 and 5000 series running full-precision diffusion models, two instances will be the sweet spot. Beyond that, bus contention degrades per-job performance faster than the parallelism benefit compensates.


Summary

Six findings from this experiment:

  1. VRAM is not the bottleneck — the CUDA execution engine and PCIe bus are. Five instances fit within VRAM limits but contend for shared compute resources, degrading per-job performance.

  2. Two instances is the throughput sweet spot on a single GPU for diffusion inference. Beyond two, diminishing returns set in quickly and per-job slowdown outweighs the parallelism benefit.

  3. No memory flag is optimal on large-VRAM cards--lowvram adds unnecessary PCIe swapping overhead when the model fits natively in VRAM.

  4. Cold run penalty is significant — first job per instance is 2–3× slower due to model loading. Warmup jobs at startup are mandatory for production deployments.

  5. asyncio.gather achieves true parallel dispatch for I/O-bound dispatchers. All jobs fire at t=0.0s and wall clock matches single-job time regardless of job count.

  6. SQLite file locking requires per-instance databases — use --database-url to give each ComfyUI instance its own isolated state file. This is an underdocumented requirement that blocks multi-instance setups silently.

  7. The bus contention finding is hardware-level, not ComfyUI-specific — the same ceiling applies to any parallel CUDA inference workload on a single GPU, across diffusion models and LLMs alike.


All timings measured on RTX 3080 Laptop 16GB, ComfyUI 0.24.0, PyTorch 2.6.0+cu124, Linux Mint 22. SD 1.5 used as the test model. Production findings to be validated on RTX 5090 32GB with FLUX.2 Klein 4B.

Top comments (0)