Monday, May 11, 2026

Building SD1.5 + ControlNet on a GTX 1660 SUPER: RAM Was the Bottleneck, Not VRAM

A record of setting up SD1.5 + ControlNet on a GTX 1660 SUPER (6GB VRAM). What tripped me up wasn't a VRAM shortage — it was three things: "RAM spiking to ~26GB and OOMing," "all-black NaN images under fp16," and "a 3.2x slowdown from forcing the math SDPA backend." Later I also discovered that every one of my step timings had been distorted by spilling. Causes and fixes, all of it.

What you'll get from this post

  • An SD1.5 configuration where ControlNet runs on a 6GB GPU
  • The fp16 NaN problem on GTX 16xx (TU116) cards and how to work around it
  • Why sequential_cpu_offload eats RAM rather than VRAM
  • Final speeds: measured figures of ~5 s/image at Hyper-SD15 1-step and ~1 min/image at a full 25 steps

What I used

Background: I killed SDXL ControlNet after 1 hour and 13 minutes

Originally I was implementing ControlNet in a different environment, built on SDXL. The implementation finished running at 21:40 on 2026-04-30 — and by 22:53 I had committed its removal. One hour and thirteen minutes.

The cause was RAM. With enable_sequential_cpu_offload under SDXL, peak VRAM stays around 4GB. But with that mechanism the entire model stays resident in RAM. Add ControlNet on top (~5GB in fp32) and:

Configuration Peak VRAM Resident RAM Result
SDXL fp32 + FaceID ~4GB ~21GB ✅ (on a 24GB machine)
+ add ControlNet ~4GB (unchanged) ~26GB ❌ OOM

VRAM was never the problem. The cost of adding ControlNet lands on the RAM side. "It fits in 6GB of VRAM, so why the OOM?" only makes sense once you understand how sequential_cpu_offload works.

How sequential_cpu_offload actually works

The diffusers enable_sequential_cpu_offload moves the UNet's ~700 submodules into VRAM one at a time, runs inference, and moves each back to RAM — repeating that every step. The whole model stays resident in RAM. That's 700 CPU↔GPU round trips per step, with the Python GIL pinning one core at 100%.

SD1.5 is ~4GB total in fp32. It fits in 6GB of VRAM, so sequential_cpu_offload becomes unnecessary. That was the motivation for standing up a separate SD1.5 environment.

The three things that tripped me up

1. All-black NaN images under fp16 (a known GTX 16xx problem)

I ran a microbenchmark across five configurations:

Configuration Time per step Output
T1: SD1.5 fp16, no CN, math SDPA 15.8 s/step all black, 1224 bytes (NaN)
T3: + 1 ControlNet, math SDPA 22.6 s/step NaN
T4: + flash + mem-efficient SDPA 7.1 s/step NaN (faster, but still)
switched to fp32 (default SDPA) see below ✅ generates correctly

Changing the SDPA backend didn't make the NaNs go away. The cause is that the GTX 1660 SUPER (TU116) has no Tensor Cores. The fp16 softmax in the attention layer underflows and overflows. It's the same problem that AUTOMATIC1111 ships an official --no-half flag for on GTX 16xx cards. Switching to fp32 fixed it immediately.

2. Forcing the math SDPA backend was causing the 3.2x slowdown

The gap between T3 (22.6 s/step) and T4 (7.1 s/step). The culprit was code carried over from the other environment:

# inherited from the other environment (written as an fp16 NaN workaround)
torch.backends.cuda.enable_flash_sdp(False)
torch.backends.cuda.enable_mem_efficient_sdp(False)
torch.backends.cuda.enable_math_sdp(True)

Deleting this and letting PyTorch pick the backend automatically gives a 3.2x improvement. That said, flash SDPA is fp16/bf16 only, so once you move to fp32 you're back on the math kernel anyway. "Delete the forcing code and move to fp32" is the correct combination.

3. Every measurement had been distorted by spilling (discovered later)

After the environment was up, I started emitting a vram_peak_mb value into the generated images' metadata — and found that fp32 with one ControlNet was peaking at 6,726 MB. The effective ceiling for a GTX 1660 SUPER under WSL2 is about 6,100 MB.

Everything had been spilling. The early numbers (13.7 s/step, 5 min 55 s for a full 25 steps) were all measured while WSL2 was spilling into host RAM.

The fix is enable_model_cpu_offload (per-model, 5–10 CPU↔GPU swaps):

Configuration --offload none (spilling) --offload model (current default) Speedup
Full 25-step + 1 CN 584.7s / VRAM 6,726 MB 61.5s / VRAM 5,298 MB 9.5x
Hyper-SD15 4-step + 1 CN 62.9s / VRAM 6,680 MB 11.1s / VRAM 5,146 MB 5.7x
Hyper-SD15 1-step 32.4s / VRAM 6,680 MB 4.9s / VRAM 5,106 MB 6.6x
Multi-CN (2) 25-step 884.6s / VRAM 8,124 MB 680.1s / VRAM 6,694 MB 1.3x (still spilling)

With a single ControlNet, VRAM settles at 5.1–5.3GB and stops spilling, which is where the 9.5x comes from. With two ControlNets, --offload model still leaves you at 6.7GB and spilling, so the gain is limited.

Low-step distillation for SD1.5: what plays the role of SDXL Lightning?

Lightning, which I'd been using on SDXL, has no SD1.5 equivalent — ByteDance only released it for SDXL. The SD1.5 options:

  • LCM-LoRA (--lcm): 4 steps, ~10 s/image
  • Hyper-SD15 CFG-distilled (--hyper {1,2,4,8}): ~5 s at 1 step. Negative prompts have no effect
  • Hyper-SD15 CFG-preserved (--hyper-cfg {8,12}): ~4.5 min at 8 steps. Negative prompts work, and it's less prone to oversaturation

For exploring poses I run large batches at --hyper 1 (5 s/image); for finishing portrait shots, --hyper-cfg 8 (with negative prompts working) is the easier tool.

How the two environments divide the work (final form)

SD1.5 environment SDXL environment
Primary use exploring poses and composition final output, FaceID portraits
Speed (full 25 steps) ~1 min/image (61.5s measured) ~16 min/image
Speed (low-step distilled) Hyper 1-step ~5 s Lightning 8-step ~6–7 min
Resolution 512×768 (native) 1280×720 / 832×1216
ControlNet OpenPose / Canny / Depth ✅ ❌ (removed due to RAM limits)

The workflow I'd recommend: use --hyper 1 on SD1.5 (5 s/image) to burn through a lot of pose and composition attempts, then hand the chosen reference image over to the SDXL environment for the final output. SD1.5's OpenPose skeleton images can't be used directly with SDXL's ControlNet models — the latent spaces differ — but you can bridge the gap by passing the original reference image in as IP-Adapter input.

FAQ

Q. Is there any way to use fp16 on a GTX 1660 SUPER?

A. For now fp32 is the only option. TU116 has no Tensor Cores, and the fp16 softmax in the attention layer underflows and overflows. It's the same known issue behind AUTOMATIC1111's --no-half and ComfyUI's --force-fp32. On RTX 3060 or later (Ampere), fp16 works and flash SDPA should get you to 1–2 s/step.

Q. Is there a way to add ControlNet to SDXL after all?

A. A configuration without FaceID (txt2img + ControlNet only) runs in about 19GB of RAM. Using FaceID and ControlNet together pushes past ~26GB, so splitting that work off to SD1.5 is the realistic choice.

Q. How long does Multi-ControlNet (two at once) take?

A. About 11 min/image, because even with --offload model you're at 6.7GB of VRAM and still spilling. The gain over a single ControlNet is limited precisely because the spilling isn't fully eliminated.

※ The steps and figures in this post were verified in a May 2026 environment (diffusers 0.38, GTX 1660 SUPER, WSL2 + Ubuntu 22.04). Behavior may change with different library or GPU driver versions. If something doesn't work, let me know in the comments.

Wrap-up

I built an SD1.5 + ControlNet environment on a GTX 1660 SUPER (6GB VRAM, 24GB RAM). After three pitfalls — fp16 NaNs, the slowdown from forcing math SDPA, and measurements distorted by VRAM spilling — I ended up with a setup that does ~5 s/image at Hyper-SD15 1-step and ~1 min/image at a full 25 steps. Splitting duties with the SDXL environment (16 min/image) makes pose exploration much faster.

If this post was useful, I'd be glad if you shared it on X.

App by the author of this blog

I made an iOS reading management app called My Bookstore. Simple bookshelf management — give it a try.

View on App Store →

References

No comments:

Post a Comment