本文档记录了如何让 LTX-2.3 的 22B 模型(46GB)
16GB 显存 + 32GB 内存的 Windows 机器上跑通,以及为此对 ltx-core 做的 4 处代码修改。

受众人群: 想在显存/内存不足的 Windows 机器上做模型推理、并想理解这些改动原理与通用性的人。


1. 背景:为什么原本跑不起来

1.1 硬件与模型的矛盾

项目 数值
模型 ltx-2.3-22b-distilled-1.1.safetensors 46 GB(bf16)
fp8-cast 量化后 27.6 GB
4bit 量化后(理论) 约 12 GB
本机显存(RTX 4060 Ti) 16 GB
本机物理内存 32 GB(空闲约 23 GB)
  • --offload none(全部进显存)→ 需 27GB+,显存装不下
  • --offload cpu(权重 pin 到内存)→ 需把约 22–27GB 权重 pin 进锁页内存,32GB 内存 pin 不下
  • --offload disk(从磁盘按需流式读取,只在 GPU 上保留 2 个块)→ 这是唯一显存/内存占用都低的路径,但在 Windows 上崩溃

--offload disk 的原理是:transformer 的每个 block 权重平时留在磁盘上,采样时后台线程逐块从磁盘读入、拷到 GPU,用完即换出。GPU 上任意时刻只驻留 2 个 block,所以显存占用极低(实测约 6.5GB)。代价是慢(每步要读盘)。

1.2 崩溃现象

--offload disk 在 Windows 上按顺序暴露了 4 个不同的崩溃/挂起,逐个修复后才跑通:

  1. 进程挂死(CPU 停摆、内存归零)——cudaHostRegister 锁页大块内存卡死。
  2. 段错误(access violation)——从 pageable 内存做异步 H2D 拷贝。
  3. 段错误——safetensors 直接读入 CUDA。
  4. 段错误——两个 safetensors mmap 句柄同时打开同一个大文件(根因)。

2. 四处修改详解

所有修改都由环境变量 LTX_DISABLE_PINNED=1 统一开启(见 run.bat)。不设该变量时,行为与官方原版完全一致——所以这些改动对 Linux/其他平台零影响

修改 1:block_streaming/utils.py — 跳过会挂死的 cudaHostRegister

问题:pinned(锁页)内存的分配走 _alloc_pinned_exact(),它调用 CUDA 的 cudaHostRegister 把一块主机内存"锁页"。在 Windows 上,对较大内存区域调用 cudaHostRegister同步遍历整个区域做页锁定,可能耗时数分钟甚至挂死。

修改:在 alloc_buffer() 开头加一个开关,LTX_DISABLE_PINNED=1 时直接不 pin,退回普通(pageable)内存。

def alloc_buffer(nbytes: int, device: torch.device | None, pin_memory: bool) -> torch.Tensor:
    if pin_memory and os.environ.get("LTX_DISABLE_PINNED") == "1":
        pin_memory = False          # ← 新增:Windows 上跳过 cudaHostRegister
    if pin_memory and not torch.cuda.is_available():
        pin_memory = False
    ...

代价:pinned 内存的作用是让 H2D 拷贝可以异步(与计算重叠)。不 pin 就只能同步拷贝,速度略降,但不影响正确性。


修改 2:block_streaming/stream_sync.py — 禁 pin 时改用同步拷贝

问题:一旦内存不是 pinned(pageable),CUDA 的异步(non_blocking=True)H2D 拷贝是未定义行为,在 Windows 上直接段错误。而 CudaStreamSync.is_async_copy 原本恒为 True

修改:禁 pin 时把异步拷贝整体关掉,拷贝改在默认流上同步执行;相关的跨流 event 也退化成 None(同步拷贝完成后 buffer 立即可复用,无需 event 守卫)。

def __init__(self, device):
    ...
    # pageable 内存 + 异步 H2D 在 Windows 上段错误;禁 pin 时强制同步拷贝
    self._async = os.environ.get("LTX_DISABLE_PINNED") != "1"

@property
def is_async_copy(self) -> bool:
    return self._async

def copy_scope(self):
    if not self._async:
        return contextlib.nullcontext()   # 不再切到独立 copy stream
    return torch.cuda.stream(self._copy_stream)

def commit_copy(self):
    if not self._async:
        return None                       # 同步拷贝已完成,无需 event
    ...

代价:失去 copy/compute 重叠,略慢。正确性不受影响。


修改 3:loader/sft_loader.py — CPU 打开 + clone 断开 mmap + 同步拷贝

问题(两个):

  • safe_open(..., device="cuda") 让 safetensors 直接把权重读进显存,在 Windows 上 get_tensor 内部访问越界。
  • 即便读到 CPU,把一个 storage 是 safetensors mmap 的张量直接 .to("cuda"),在 Windows 上也会在 storage 切片处段错误。

修改:禁 pin 时,①在 CPU 上打开;②先 .clone() 让张量脱离 mmap 存储,拿到普通内存;③再做同步 H2D。

# 定义在文件顶部
_NON_BLOCKING = os.environ.get("LTX_DISABLE_PINNED") != "1"

# load() 内
open_device = "cpu" if not _NON_BLOCKING else str(device)
with safetensors.safe_open(shard_path, framework="pt", device=open_device) as f:
    for name in f.keys():
        ...
        value = f.get_tensor(name)
        if not _NON_BLOCKING:
            value = value.clone()     # ← 脱离 mmap 存储
        value = value.to(device=device, non_blocking=_NON_BLOCKING, copy=False)

代价:clone() 多一次内存拷贝,略增内存峰值(单张量级别,可忽略)。


修改 4(根因):block_streaming/disk.pyDiskTensorReader 延迟打开句柄

问题(真正的根因):disk offload 运行时,两个 safetensors mmap 句柄同时打开同一个 46GB 文件:

  • 一个是 block streaming 的 DiskTensorReader(读 transformer 块权重);
  • 另一个是 sft_loader 加载 non-block 权重(VAE、embedding 等)时打开的。

经最小复现验证:在 Windows 上,只要对同一大文件同时存在两个 safe_open 句柄,读取时就段错误(与是否用 CUDA、是否并发读无关——两个句柄"共存"即崩)。而顺序打开(任一时刻只有一个句柄)完全正常。

修改:让 DiskTensorReader 延迟打开——构造时只用一个短生命周期句柄读一遍 key 列表就关掉;真正的长生命周期 mmap 句柄推迟到首次 get_tensor(即块 fetch)时才打开。而块 fetch 发生在 non-block 权重加载之后,于是两个句柄不再共存。

class DiskTensorReader:
    def __init__(self, paths):
        self._paths = list(paths)
        self._handles = None                       # ← 延迟打开
        self._key_to_handle_idx = {}
        # 只用短命句柄读一次 key 列表,读完立即关闭
        for handle_idx, path in enumerate(self._paths):
            with safetensors.safe_open(path, framework="pt", device="cpu") as handle:
                for sft_key in handle.keys():
                    self._key_to_handle_idx[sft_key] = handle_idx

    def _ensure_open(self):
        if self._handles is None:                  # ← 首次 get_tensor 才真正打开
            self._handles = [safetensors.safe_open(p, framework="pt", device="cpu")
                             for p in self._paths]
        return self._handles

    def get_tensor(self, key):
        handles = self._ensure_open()
        return handles[self._key_to_handle_idx[key]].get_tensor(key)

代价:几乎为零。仅把长生命周期句柄的打开时机往后挪。


3. 复现命令

关键验证脚本(证明根因是"两个句柄共存"):

# 崩溃:两个句柄同时打开同一大文件,纯读、不碰 CUDA 也段错误
import safetensors
p = "models/ltx-2.3-22b-distilled-1.1.safetensors"
h1 = safetensors.safe_open(p, framework="pt", device="cpu")
h2 = safetensors.safe_open(p, framework="pt", device="cpu")
for k in h2.keys():
    if ".transformer_blocks." not in k:
        h2.get_tensor(k)        # ← 这里 access violation

# 正常:顺序打开(关掉 h1 再开 h2),即使 .to("cuda") 也没问题
h1 = safetensors.safe_open(p, framework="pt", device="cpu"); del h1
h2 = safetensors.safe_open(p, framework="pt", device="cpu")
for k in h2.keys():
    if ".transformer_blocks." not in k:
        h2.get_tensor(k).clone().to("cuda")   # ← 全部成功

4. 运行方式

run.bat 已配置好:

@echo off
set LTX_DISABLE_PINNED=1
uv run python -m ltx_pipelines.distilled ^
    --distilled-checkpoint-path "models/ltx-2.3-22b-distilled-1.1.safetensors" ^
    --spatial-upsampler-path "models/ltx-2.3-spatial-upscaler-x2-1.1.safetensors" ^
    --gemma-root "models/gemma-3-12b" ^
    --offload disk ^
    --quantization fp8-cast ^
    --height 512 --width 320 --num-frames 121 ^
    --seed 42 --output-path "output.mp4" ^
    --prompt "..."

实测:GPU 占用约 6.5GB,采样每步约 27 秒。质量为满血 22B 无损。


5. 这些修改是否具有通用性?

部分通用,需要分开看。

✅ 通用的部分

  • 根因(修改 4)是通用的。"Windows 上同一大文件的多个 safetensors mmap 句柄并发会崩"是 safetensors/torch 在 Windows 上的底层行为,与 LTX 无关。任何在 Windows 上、对大 checkpoint 同时开多个 safe_open 句柄的项目(尤其是 offload / 分片流式加载类)都可能踩到,延迟打开/顺序化句柄的思路可直接借鉴。
  • 修改 1/2/3 针对的三类问题(大块 cudaHostRegister 挂死、pageable 内存异步 H2D 段错误、mmap 张量直接 .to(cuda) 崩)都是 Windows + CUDA 的共性坑,在其他做手动内存 offload 的推理框架里同样成立。原理层面完全可迁移。

⚠️ 不能直接照搬的部分

  • 这些补丁是针对 LTX-2.3 的 ltx-core 代码结构写的(具体函数名、LTX_DISABLE_PINNED 开关、调用时序)。换一个项目要重新定位对应代码位置,不能原样复制文件。
  • 修改 1/2 是用速度换稳定(放弃 pinned 内存和异步拷贝)。在 pinned 内存工作正常的平台(Linux)上不该开启,否则白白损失性能——所以用环境变量做成"仅 Windows/需要时启用"。
  • 只在这一套硬件 + 这个模型上验证过。不同显存/内存、不同模型大小,阈值和是否需要 disk offload 会变。

❌ 不通用/局限

  • 治标不治本:根本矛盾仍是"16GB 显存跑 46GB 模型"。disk offload 只是让它能跑,不是跑得快——每步读盘,速度受磁盘 IO 限制。想要快,正解仍是更大显存,或对模型做真正的 4bit 量化(把模型压到能整体进显存)。
  • 官方未来若重构加载器或升级 safetensors 修复了 Windows mmap 问题,这些补丁可能需要重新适配或不再需要。

建议

  • 上游价值最高的是修改 4。可以考虑给 ltx-core / safetensors 提 issue 或 PR:说明 Windows 上并发 mmap 句柄的问题,并附上第 3 节的最小复现。这对社区最有用。
  • 其余 3 处更像"Windows 兼容性开关",适合以 LTX_DISABLE_PINNED 这类可选开关的形式长期保留,而非改变默认行为。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐