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 函式庫(transformers、diffusers)來載入模型,目標就很明確了:我們只需引入一個猴子補丁呼叫,其餘程式碼保持不變,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 張量的內部結構:
# 從 torch.multiprocessing.reductions 複製,大部分程式碼已移除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))相當簡單:一個張量就是它的型別、元資料和底層儲存。這裡的 storage 是 TypedStorage 型別,但實際上 TypedStorage 只是 UntypedStorage 的一個簡單包裝。UntypedStorage 才是真正儲存所有張量資料的類別。
我們的任務現在更具體了:如何避免複製 UntypedStorage?我們能否自行管理這些張量記憶體,並透過指向我們管理的記憶體來建構 UntypedStorage?
答案是肯定的!
快速瀏覽 UntypedStorage 建構的 C++ 程式碼,我們可以輕易找到類似這樣的程式片段:
// 複製自 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 物件,bytes 和 mmap 是兩個主要支援它的東西,也是我們關心的對象。
最後,我們知道該做什麼了:建立一個函式,接受 memoryview 物件並將其轉換為 UntypedStorage,且不進行複製。有了從 memoryview 重建 UntypedStorage 的能力,實際的張量資料就不必放在 pickle 串流中,大幅減少了需要複製的資料量。
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_RIGHTS 的 sendmsg。我們可以借助函式庫來隱藏 sendmsg 過程中繁瑣的細節,但仍然需要在伺服器和客戶端程序之間進行協調。我們決定在這裡使用一個技巧:在客戶端直接開啟 /proc/{pidof(server)}/fd/{memfd},同時在 overmind 伺服器端永遠不關閉該檔案描述符。唯一需要的通訊就是一個 (pid, fd) 元組。在我們的情境中,這個方法運作得非常完美。
以上內容可以歸結為以下幾行程式碼:
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 函式:
# 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.dumps 和 OvermindPickler.loads 就會利用共享記憶體來加速。如果你已經受夠了,可以在這裡停止閱讀。剩下的都是細節。
魔鬼藏在細節中
為什麼不使用 PyTorch 內建的張量共享?
所謂的「內建張量共享」,我指的是 torch.multiprocessing.reductions。
- 在高層次上,PyTorch 的方法是設計用於「將張量傳遞給子程序」,看似相同,但存在細微差異。
- PyTorch 使用 POSIX 共享記憶體來共享記憶體,會受到前面提到的限制。
- 對於每個張量(或
UntypedStorage),即使它只包含 4 個位元組,PyTorch 也會為其分配一個專用的 POSIX 共享記憶體物件。每個物件都會消耗一個檔案描述符。 - PyTorch 在反序列化後會立即釋放 POSIX 共享記憶體,這使得它不適合我們的需求。我們需要多次反序列化同一個 pickle 串流。
- 其中有許多與 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 檔案寫入磁碟,因此我們只需匯入該模組即可解決問題。
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),因此我們必須自行處理。
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),這些無法被序列化,我們必須將其移除:
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 的類別),它不會序列化該內容,而只會保留對實際類別的引用。我們已經修補了相關邏輯,使其在「扁平化」後的串流中實際儲存一個序列化後的類別。
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這裡還有兩個技巧:
- 我們使用
ZIP_STORED重新打包 ZIP 檔案,這樣後續載入時就不需要每次都解壓縮 ZIP 檔案。 torch.jit.load介面也存在記憶體複製的問題,因此我們撰寫了一個簡單的包裝器,透過 Python 緩衝區協定來載入,就像UntypedStorage一樣。
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 模式
我們的程式碼庫中有類似這樣的寫法,它嘗試以先前載入的模型作為參數來載入另一個模型:
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 模式腳本來進行測試。
| 測試 | vae | depth | edge | pipeline | to('cuda') | 總計 |
|---|---|---|---|---|---|---|
| 無優化,第1次 | 1.18 | 0.98 | 1.41 | 1.65 | 0.91 | 6.16 |
| 無優化,第2次 | 1.15 | 0.96 | 0.97 | 1.65 | 0.89 | 5.66 |
| 無優化,第3次 | 1.15 | 0.96 | 0.98 | 1.61 | 0.91 | 5.65 |
| 無,第4次 | 1.42 | 1.10 | 1.11 | 1.72 | 0.88 | 6.27 |
| 無,第5次 | 1.28 | 1.08 | 1.10 | 1.72 | 0.92 | 6.13 |
| 有,第1次 | 5.44 | 5.17 | 5.41 | 7.29 | 0.86 | 24.20 |
| 有,第2次 | 0.00 | 0.01 | 0.01 | 0.20 | 0.87 | 1.12 |
| 有,第3次 | 0.01 | 0.01 | 0.01 | 0.21 | 0.86 | 1.12 |
| 有,第4次 | 0.01 | 0.01 | 0.01 | 0.20 | 0.90 | 1.15 |
| 有,第5次 | 0.01 | 0.01 | 0.01 | 0.21 | 0.86 | 1.13 |
如您所見,使用 overmind 的初始載入耗時 24.2 秒,遠比未使用時長。然而,在後續載入中,僅剩下 .to('cuda') 的成本。
將所有序列化模型檔案的大小加總,整個管線估計約使用 5808 MB 的記憶體。快速基準測試也得到類似結果。
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 上開源此專案,若能對您有所幫助,我們將感到十分開心。

![如何在 Blender 中匯入 3D 模型 [2 種方法]](https://cdn.meshy.ai/ti_w:3840,q:75/landing-assets/blog/how-to-import-models-into-blender/how-to-import-models-into-blender-cover.webp)
![3D模型檔案格式:類型、副檔名、應用場景 [與更多]](https://cdn.meshy.ai/ti_w:3840,q:75/landing-assets/blog/3d-file-formats/3d-file-formats-cover.webp)


![3MF 對比 STL:品質、檔案大小、使用場景 [與更多]](https://cdn.meshy.ai/ti_w:3840,q:75/landing-assets/blog/3mf-vs-stl/3mf-vs-stl-cover.webp)



![3MF 檔案檢視器:線上開啟 .3MF 模型 [免費]](https://cdn.meshy.ai/ti_w:3840,q:75/landing-assets/tools/viewer_og.webp)
