公告

Overmind:將 ML 模型載入時間從 15 秒縮減至 0.2 秒

Meshy 如何打造 Overmind — 一個利用零拷貝共享記憶體將 ML 模型載入時間從 15 秒縮減至 0.2 秒的開源函式庫,且無需修改推論程式碼

Bin Wang, Senior Infrastructure Engineer
發布日期:2026年3月6日

TL;DR ML 模型載入速度緩慢,即使 Linux 頁面快取已預熱也是如此。因此我們建立了一個函式庫來加速載入過程。 其中有幾個有趣的技術細節想與大家分享,於是寫了這篇部落格文章。 這個函式庫還帶來了意想不到的影響,將在結尾處討論。

動機

這一切始於兩年前,當時我們首次推出低多邊形生成模式。這個模式表現不佳,以現在的標準來看效果很差,但我們為此付出了高昂的代價——一台專用 GPU 每天只能處理個位數的任務。這個模型有微調過的權重,大到足以將其他所有模型權重擠出 VRAM。更糟的是,我們大概有 3 個這樣的模型(確切數字記不清了),它們佔據了推理基礎設施的很大一部分,導致效率比非常不理想。而且,我們不能簡單地即時載入模型,因為這需要 30 秒,比實際處理時間還長。

當時我們沒有專門的管線工程師,演算法開發人員盡力尋找解決方法。幾天後,我們的程式碼庫中充斥著 this.to('cpu')that.to('cuda')。這種方法暫時可行,但時常打斷演算法開發人員的工作流程。如果一切能自動發生呢?這是 Python,在 Python 中事情確實會自動發生。

如何定義「自動化」?

讓我們從演算法開發人員的角度來看。事情很明確:除非絕對必要,否則我不想關心核心演算法之外的執行時期效能。我寧願完全不知道模型交換的細節。

當然我們無法做到這點,但可以盡量減少對演算法程式碼的侵入。這讓我想起了 gevent 函式庫的猴子補丁(monkey-patching),它主要修補 socket 函式庫,將其替換為 gevent.socket,後者能在 IO 阻塞時切換到其他協程,很像 goroutine(實際上 gevent 比 Golang 還老!)。

由於當時我們只使用 HuggingFace 函式庫(transformersdiffusers)來載入模型,目標就很明確了:我們只需引入一個猴子補丁呼叫,其餘程式碼保持不變,XXXPipeline.from_pretrained(...) 應該要快得多。

一些事實、顯而易見的決策與假設

Overmind 是一個快取函式庫,它將模型載入呼叫的結果快取到系統記憶體中,之後再快速重建。

我們跳過猴子補丁的實作細節,因為這不是很有趣。我們只需要知道,它會將所有 XXXPipeline.from_pretrained(...) 呼叫重新導向到 overmind.api.load(XXXPipeline.from_pretrained, ...)

我們使用 pickle 來序列化快取結果,因為……我們別無選擇,而且 torch.save 本身也使用 pickle,不用它反而奇怪。

我們採用客戶端/伺服器架構,因為不希望程序終止時快取失效。許多子程序呼叫都能從中受益。

我們假設 XXXPipeline.from_pretrained 的參數是簡單的可雜湊物件(str 之類的),以及其他由 overmind 載入的模型(稍後說明)。

你可能已經猜到,overmind 這個名字借用了《星海爭霸》。

快速重建!

我們不能單純地將 pickle.loads 的結果保存在記憶體中就算了。畢竟,在預熱場景下,Linux 頁面快取已經快取了磁碟上的模型,但我們仍然看到載入時間需要幾十秒。

效率低下的原因在於記憶體複製。在 Python 中,即使建立數百萬個物件,花費的時間也不會超過幾百毫秒。然而,對於 10GiB 的記憶體複製,則需要半秒鐘。我們必須盡可能避免記憶體複製。

幸運的是,大部分大型記憶體區塊都是 Torch 張量,我們可以只針對它們進行處理,忽略其餘部分。

實際上,我在研究張量共享機制時,從縮減程式碼中了解了 Torch 張量的內部結構:

python
# 從 torch.multiprocessing.reductions 複製,大部分程式碼已移除
python
def reduce_tensor(tensor):
    ...
    storage = tensor._typed_storage()
    ...
    metadata = (
        tensor.storage_offset(),
        tensor.size(),
        tensor.stride(),
        tensor.requires_grad,
    )
    return (rebuild_tensor, (type(tensor), storage, metadata))

相當簡單:一個張量就是它的型別、元資料和底層儲存。這裡的 storageTypedStorage 型別,但實際上 TypedStorage 只是 UntypedStorage 的一個簡單包裝。UntypedStorage 才是真正儲存所有張量資料的類別。

我們的任務現在更具體了:如何避免複製 UntypedStorage?我們能否自行管理這些張量記憶體,並透過指向我們管理的記憶體來建構 UntypedStorage

答案是肯定的!

快速瀏覽 UntypedStorage 建構的 C++ 程式碼,我們可以輕易找到類似這樣的程式片段:

cpp
// 複製自 torch/csrc/Storage.cpp
static PyObject* THPStorage_get(THPStorage* self, PyObject* index) {
    // ...省略不相關的程式碼...

    auto new_storage_impl = make_storage_impl(
        c10::StorageImpl::use_byte_size_t(),
        slicelength,
        at::DataPtr(
            static_cast<void*>(data + start),
            old_storage_impl,
            [](void* s) {
              c10::raw::intrusive_ptr::decref(static_cast<at::StorageImpl*>(s));
            },
            old_storage_impl->device()),
        old_storage_impl->allocator(),
        /* resizable */ false,
        device_opt);

    PyObject* _ret =
        THPStorage_NewWithStorage(Py_TYPE(self), std::move(new_storage_impl));

    return _ret;
}

我們不僅可以使用指標,at::DataPtr 類別還能處理解構,讓生命週期管理變得簡單許多。

在 Python 端,記憶體區域的指標由 memoryview 物件表示,這些物件支援緩衝區協定。我們可以從許多東西取得 memoryview 物件,bytesmmap 是兩個主要支援它的東西,也是我們關心的對象。

最後,我們知道該做什麼了:建立一個函式,接受 memoryview 物件並將其轉換為 UntypedStorage,且不進行複製。有了從 memoryview 重建 UntypedStorage 的能力,實際的張量資料就不必放在 pickle 串流中,大幅減少了需要複製的資料量。

cpp
void initOvermindHelpers(py::module m) {
    // ...
    m.def("_make_untyped_storage", [](py::buffer b) {
        auto info = new py::buffer_info(b.request());

        return pybind11::reinterpret_steal<py::object>(THPStorage_NewWithStorage(
            THPStorageClass,
            c10::make_intrusive<at::StorageImpl>(
                c10::StorageImpl::use_byte_size_t(),
                info->size,
                at::DataPtr(
                    info->ptr, info,
                    [](void* ptr) {
                        py::gil_scoped_acquire gil;
                        auto b = static_cast<py::buffer_info*>(ptr);
                        delete b;
                    },
                    at::DeviceType::CPU
                ),
                /*allocator=*/nullptr,
                /*resizable=*/false,
            )
        ));
    });
}

這就是 overmind 的核心建構區塊。

共享張量!

注意: PyTorch 已經有張量共享機制,但不符合我們的需求。稍後會詳細說明。

首先,在客戶端與伺服器之間共享記憶體

當我們看到「共享」和「記憶體」同時出現時,都會有使用 shmget 及其相關函式的衝動。它「設計」用來作為記憶體共享機制,對吧?但它有兩個主要缺點:

  • POSIX 共享記憶體是稀缺資源,可用量取決於系統管理員如何配置系統。一個極端但普遍的例子是 Docker 容器,預設情況下只有 64MiB 的 POSIX 共享記憶體可用。
  • POSIX 共享記憶體的生命週期比你的程序更長,你必須自行管理。如果管理程序被強制終止,或處理不當,共享記憶體物件可能會無限期地殘留在系統中。

仔細觀察的話,Linux 充滿了有趣的系統呼叫。memfd_create 就是我們感興趣的一個:它會回傳一個代表匿名記憶體分配的檔案描述符。你可以對它執行各種檔案操作:讀取、寫入,當然還有 mmap。如果我們能共享這個檔案描述符,就能共享記憶體。

共享檔案描述符有一種「標準」但晦澀的方法:使用 SCM_RIGHTSsendmsg。我們可以借助函式庫來隱藏 sendmsg 過程中繁瑣的細節,但仍然需要在伺服器和客戶端程序之間進行協調。我們決定在這裡使用一個技巧:在客戶端直接開啟 /proc/{pidof(server)}/fd/{memfd},同時在 overmind 伺服器端永遠不關閉該檔案描述符。唯一需要的通訊就是一個 (pid, fd) 元組。在我們的情境中,這個方法運作得非常完美。

以上內容可以歸結為以下幾行程式碼:

python
class SharedMemory:
    @classmethod
    def create(cls, shift):
        # 在伺服器端呼叫
        libc = ctypes.CDLL(None)
        name = _make_filename(shift).encode('utf-8')
        fd = libc.memfd_create(name, os.O_RDWR)
        os.ftruncate(fd, 1 << shift)
        mem_id = (os.getpid(), fd)
        return cls(fd=fd, mem_id=mem_id)

    @classmethod
    def rebuild(cls, mem_id):
        # 在客戶端呼叫
        pid, fd = mem_id
        local_fd = os.open(f'/proc/{pid}/fd/{fd}', os.O_RDWR)
        return cls(fd=local_fd, mem_id=mem_id)
    
    def get_buffer(self):
        # 在兩端皆可呼叫
        self._mmap = mmap.mmap(self._fd, size)
        self._buf = memoryview(self._mmap)
        return self._buf

與 Pickling 整合

如同我們先前討論的,我們需要修改 UntypedStorage 的 pickling 過程。類似於 torch.multiprocessing.reductions 中的實作,我們為 pickle 定義了自訂的 reduce 函式:

python
# Hoarder 和 borrower 是上述 SharedMemory 的包裝器,
# 包含記憶體區域等無趣的內容。
def _reduce_storage(storage):
    # 由伺服器呼叫
    device = storage.device
    storage = storage.cpu()

    # 將內容儲存到共享記憶體中
    # `frag` 包含定位內容所需的完整資訊。
    frag = hoarder.put(storage)

    return (_rebuild_storage_on_client, (frag, device))

def _rebuild_storage_on_client(frag, device):
    # 由客戶端呼叫
    mv = borrower.borrow(frag)  # 從共享記憶體取得 memoryview
    storage = _make_untyped_storage(mv)  # 零拷貝!
    if device.type == 'cuda':
        return storage.cuda(device.index)
    return storage

class OvermindPickler(dill.Pickler):
    ...

OvermindPickler.register(torch.storage.UntypedStorage, _reduce_storage)

現在,簡單的 OvermindPickler.dumpsOvermindPickler.loads 就會利用共享記憶體來加速。如果你已經受夠了,可以在這裡停止閱讀。剩下的都是細節。

魔鬼藏在細節中

為什麼不使用 PyTorch 內建的張量共享?

所謂的「內建張量共享」,我指的是 torch.multiprocessing.reductions

  1. 在高層次上,PyTorch 的方法是設計用於「將張量傳遞給子程序」,看似相同,但存在細微差異。
  2. PyTorch 使用 POSIX 共享記憶體來共享記憶體,會受到前面提到的限制。
  3. 對於每個張量(或 UntypedStorage),即使它只包含 4 個位元組,PyTorch 也會為其分配一個專用的 POSIX 共享記憶體物件。每個物件都會消耗一個檔案描述符。
  4. PyTorch 在反序列化後會立即釋放 POSIX 共享記憶體,這使得它不適合我們的需求。我們需要多次反序列化同一個 pickle 串流。
  5. 其中有許多與 CUDA 相關的共享邏輯,對我們的使用情境來說純屬干擾和麻煩。

為什麼你說「張量資料被多次複製」?

對於典型的磁碟 torch.load

  • 磁碟上的 torch.save 檔案會被讀取到記憶體中。
  • 透過 Zip 檔案解壓縮取得實際的 torch.UntypedStorage 資料(以 bytes 形式)(torch.save 會產生一個 Zip 檔案)。
  • C++ 程式碼會在 torch.UntypedStorage 建構子中將資料複製到其自行管理的記憶體中。

對於單純的 pickle.dumps 及後續的 pickle.loads

  • 產生的 pickle 串流內部會嵌入另一個 pickle 串流,pickle.loads 會將內部的串流複製到一個新的 bytes 中。
  • torch.UntypedStorage 的資料嵌入在內部的 pickle 串流中,在 torch.UntypedStorage 建構時會發生另一次複製。
  • C++ 程式碼會在 torch.UntypedStorage 建構子中將資料複製到其自行管理的記憶體中。

diffusers 具有動態模組

模型儲存庫可能包含 Python 檔案,這些檔案會在執行時被匯入到 diffusers_modules 命名空間中。客戶端沒有將這些檔案放在 sys.path 中,這會導致反序列化(unpickling)失敗。幸運的是,diffusers 會將這些動態 Python 檔案寫入磁碟,因此我們只需匯入該模組即可解決問題。

python
def diffusers_dyn_module_workaround():
    from diffusers.utils.constants import HF_MODULES_CACHE
    modpath = Path(HF_MODULES_CACHE) / "diffusers_modules/__init__.py"
    spec = importlib.util.spec_from_file_location("diffusers_modules", modpath)
    sys.modules["diffusers_modules"] = importlib.util.module_from_spec(spec)

支援 bitsandbytes

支援 bitsandbytes 最惱人的地方在於量化過程需要在 GPU 上進行。一旦我們在 overmind 伺服器中初始化了 CUDA 和 torch,就沒有簡單的方法可以取消初始化,這可能會對實際工作負載造成問題(主要是可用 VRAM 減少)。因此,我們修改了伺服器,使其產生一個子程序,將模型載入到共享記憶體中,然後終止子程序。這恰好也提高了 overmind 伺服器的穩定性。

量化後的參數是 bitsandbytes 提供的特殊子類別。這些子類別在設計時並未考慮「可序列化性」(picklability),因此我們必須自行處理。

python
def _reduce_bnb_param(p):
    dev = p._prev_device
    assert p.quant_state
    return (_rebuild_bnb_param, (type(p), p.data, p.quant_state.as_dict(packed=True), dev))


def _rebuild_bnb_param(typ, data, qs_dict, dev):
    return typ.from_prequantized(data, qs_dict, device=dev)


def bitsandbytes_quirks():
    try:
        import bitsandbytes
    except ImportError as e:
        return

    ForkingPickler.register(bitsandbytes.nn.modules.Params4bit, _reduce_bnb_param)
    ForkingPickler.register(bitsandbytes.nn.modules.Int8Params, _reduce_bnb_param)

透過 bitsandbytes 進行量化的模型會附帶一些鉤子(hooks)和猴子補丁(monkey-patches),這些無法被序列化,我們必須將其移除:

python
from accelerate.hooks import remove_hook_from_module
remove_hook_from_module(model, True)
model.__dict__.pop('to', None)  # 移除警告猴子補丁
model.__dict__.pop('cuda', None)

我們也遇到過函式巢狀在其他函式內部(而非位於頂層)的情況,這使得它們無法被序列化。我們嘗試過解決這個問題,但沒有成功。我們不得不將序列化方式從標準函式庫提供的版本切換到 dill 來處理這種情況。dill 功能強大得多,但它是純 Python 實作,速度比標準函式庫版本慢很多。幸運的是,這個成本只在我們首次載入模型時付出一次(僅影響序列化過程,不影響反序列化過程)。

支援 stable-fast

stable-fast 會產生 torch.compile 的結果,這些結果無法被序列化。但使用 torch.jit.save,我們可以將結果儲存為 Zip 檔案。這聽起來效率不高,但總比沒有好。

僅使用 torch.jit.save 並不足以序列化 stable-fast 的結果。stable-fast 使用「扁平化」(flatten)過程來使 Torch 模組可追蹤。當遇到無法識別的內容時(例如 dataclass 的類別),它不會序列化該內容,而只會保留對實際類別的引用。我們已經修補了相關邏輯,使其在「扁平化」後的串流中實際儲存一個序列化後的類別。

python
def stable_fast_quirks():
    ...

    # 將 dataclass 類型進行 pickle,而不是僅將其放入容器中(容器在 torch.jit.save 後無法保留)
    def flatten_dataclass(obj):
        from sfast.utils.flat_tensors import flatten_bytes, flatten_dict
        import dataclasses
        d = dict((field.name, getattr(obj, field.name))
                for field in dataclasses.fields(obj))
        import pickle
        pickled = pickle.dumps(obj.__class__)
        return flatten_bytes(pickled) + flatten_dict(d)

    def unflatten_dataclass(tensors, start):
        from sfast.utils.flat_tensors import unflatten_bytes, unflatten_dict
        import pickle
        pickled, start = unflatten_bytes(tensors, start)
        clz = pickle.loads(pickled)
        content, start = unflatten_dict(tensors, start)
        return clz(**content), start

    sfast.utils.flat_tensors.flatten_dataclass = flatten_dataclass
    sfast.utils.flat_tensors.unflatten_dataclass = unflatten_dataclass

這裡還有兩個技巧:

  1. 我們使用 ZIP_STORED 重新打包 ZIP 檔案,這樣後續載入時就不需要每次都解壓縮 ZIP 檔案。
  2. torch.jit.load 介面也存在記憶體複製的問題,因此我們撰寫了一個簡單的包裝器,透過 Python 緩衝區協定來載入,就像 UntypedStorage 一樣。
cpp
void initOvermindHelpers(py::module m) {
    // ...
    m.def("import_ir_module_from_buffer_0copy",
        [](std::shared_ptr<torch::jit::CompilationUnit> cu, py::buffer buffer) {
            auto info = buffer.request();
            imemstream in((char*)info.ptr, info.size);  // 無複製!
            return import_ir_module(std::move(cu), in, ...);
        }
    );
}

vae=vae 模式

我們的程式碼庫中有類似這樣的寫法,它嘗試以先前載入的模型作為參數來載入另一個模型:

python
import overmind.api
overmind.api.monkey_patch_all()

import torch
from diffusers.models import AutoencoderKL

from diffusers import (
    ControlNetModel,
    StableDiffusionControlNetPipeline,
)

vae = AutoencoderKL.from_pretrained(
    "lemon2431/ChineseInkComicStrip_v10",
    subfolder="vae",
    torch_dtype=torch.float16,
)
controlnet_depth = ControlNetModel.from_pretrained(
    "lllyasviel/control_v11f1p_sd15_depth",
    torch_dtype=torch.float16,
    variant="fp16",
)
controlnet_edge = ControlNetModel.from_pretrained(
    "lllyasviel/control_v11p_sd15_softedge",
    torch_dtype=torch.float16,
    variant="fp16",
)

pipeline = StableDiffusionControlNetPipeline.from_pretrained(
    "lemon2431/ChineseInkComicStrip_v10",
    vae=vae,  # 這裡!
    controlnet=[controlnet_edge, controlnet_depth],  # 還有這裡!
    torch_dtype=torch.float16,
    safety_checker=None,
)

pipeline.to('cuda')

如同我們先前所說,函式參數預設是簡單、易於 pickle 的物件,但這種模式打破了這個假設。為了解決這個問題,我們加入了特殊邏輯:每個快取結果都會附加一個 ID。如果該物件被用作另一個呼叫的參數,客戶端會用它的 ID 來取代,而伺服器則可以根據 ID 還原出實際的物件。

最終的 pipeline 模型會包含對 vae 的參照。為了簡化,我們這裡直接對它進行 pickle。不過,在將實際的 UntypedStorage 移動到共享記憶體時,我們會對重複的資料進行去重。

我們可能使用了 pickle 的 persistent_id 機制,但我沒有嘗試這個方法。這有點可惜。

基準測試

現在進入大家最喜歡的環節。

我們使用上一節的 VAE 模式腳本來進行測試。

測試vaedepthedgepipelineto('cuda')總計
無優化,第1次1.180.981.411.650.916.16
無優化,第2次1.150.960.971.650.895.66
無優化,第3次1.150.960.981.610.915.65
無,第4次1.421.101.111.720.886.27
無,第5次1.281.081.101.720.926.13
有,第1次5.445.175.417.290.8624.20
有,第2次0.000.010.010.200.871.12
有,第3次0.010.010.010.210.861.12
有,第4次0.010.010.010.200.901.15
有,第5次0.010.010.010.210.861.13

如您所見,使用 overmind 的初始載入耗時 24.2 秒,遠比未使用時長。然而,在後續載入中,僅剩下 .to('cuda') 的成本。

將所有序列化模型檔案的大小加總,整個管線估計約使用 5808 MB 的記憶體。快速基準測試也得到類似結果。

text
In [1]: t = torch.ones((5808, 1024, 1024), dtype=torch.uint8)

In [2]: %time a = t.cuda()
CPU times: user 976 ms, sys: 874 μs, total: 977 ms
Wall time: 976 ms

In [3]: %timeit a = t.cuda()
1.01 s ± 56.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

測試環境:Intel i9-11900K + GeForce RTX 4090。

意想不到的正面副作用!

我們建立 overmind 的主要動機是為了在推論期間快速切換模型權重。雖然它達成了這個目的,但我們也發現了幾個額外的好處。

我們為每個 GPU 部署了多個應用程式實例,因此每個節點會有 8 個程序。部署 overmind 後,系統記憶體使用量大幅減少。我們原本並未面臨系統記憶體不足的問題,但若真有此困擾,這將是一大勝利。

後來,我們發現這對演算法和管線開發者來說是一大助力。在每次修改驗證的循環中,我們可以節省 10 到 20 秒的載入時間,累積起來相當可觀。更重要的是,省下的這些秒數能讓開發者保持流暢的工作狀態。

GitHub

我們已在 GitHub 上開源此專案,若能對您有所幫助,我們將感到十分開心。

探索更快速推論的無限可能
Overmind 為 Meshy 的 AI 3D 生成技術提供極速動力。立即體驗,親眼見證成果。
這篇文章對你有幫助嗎?

3D,隨傳隨到

聯絡銷售