ประกาศ

Overmind: ลดเวลาในการโหลดโมเดล ML จาก 15 วินาทีเหลือ 0.2 วินาที

Meshy สร้าง Overmind อย่างไร — ไลบรารีโอเพ่นซอร์สที่ลดเวลาในการโหลดโมเดล ML จาก 15 วินาทีเหลือ 0.2 วินาที โดยใช้หน่วยความจำร่วมแบบ zero-copy โดยไม่ต้องเปลี่ยนแปลงโค้ดการอนุมาน

Bin Wang, Senior Infrastructure Engineer
โพสต์: 6 มีนาคม 2569
สารบัญ

TL;DR การโหลดโมเดล ML ช้า แม้จะมีการเตรียมแคชหน้าเพจของ Linux ไว้แล้ว ดังนั้นเราจึงสร้างไลบรารีเพื่อทำให้มันเร็วขึ้น มีรายละเอียดทางเทคนิคที่น่าสนใจที่เราอยากแบ่งปัน ดังนั้นเราจึงเขียนบล็อกนี้ ไลบรารียังมีผลกระทบที่ไม่คาดคิด ซึ่งจะกล่าวถึงในตอนท้าย

เหตุผล

ทั้งหมดเริ่มต้นเมื่อ 2 ปีที่แล้ว เมื่อเราส่งโหมดการสร้าง lowpoly เป็นครั้งแรก โหมด lowpoly ไม่เป็นไปด้วยดี มันให้ผลลัพธ์ที่ไม่ดีจากมุมมองของวันนี้ แต่เราจ่ายเงินไปมากสำหรับมัน -- GPU เฉพาะทางประมวลผลงานได้เพียงตัวเลขหลักเดียวต่อวัน มันมีน้ำหนักที่ปรับแต่งมาอย่างดี ใหญ่พอที่จะขับไล่น้ำหนักโมเดลอื่น ๆ ออกจาก VRAM ที่แย่กว่านั้น เรามีโมเดลแบบนี้ประมาณ 3 โมเดล (จำจำนวนที่แน่นอนไม่ได้) พวกมันเป็นส่วนสำคัญของโครงสร้างพื้นฐานการอนุมานของเรา ทำให้อัตราประสิทธิภาพค่อนข้างไม่ให้อภัย และไม่ เราไม่สามารถโหลดโมเดลแบบ just-in-time ได้อย่างง่ายดาย มันใช้เวลา 30 วินาที ซึ่งใหญ่กว่าความจริงที่ใช้ในการประมวลผล

ตอนนั้นเราไม่มีวิศวกรท่อส่งข้อมูลเฉพาะทาง นักพัฒนาการอัลกอริทึมของเราพยายามอย่างดีที่สุดเพื่อหาวิธีแก้ปัญหานี้ หลายวันต่อมา โค้ดเบสของเราถูกทิ้งด้วย this.to('cpu') และ that.to('cuda') วิธีนี้ใช้ได้ผลชั่วคราว แต่ทำให้การทำงานของนักพัฒนาอัลกอริทึมของเราหยุดชะงักเป็นครั้งคราว จะเกิดอะไรขึ้นถ้าสิ่งต่าง ๆ สามารถเกิดขึ้นได้โดยอัตโนมัติ? นี่คือ Python สิ่งต่าง ๆ เกิดขึ้นโดยอัตโนมัติใน Python

คุณจะกำหนด 'โดยอัตโนมัติ' อย่างไร?

ลองเข้าสู่บทบาทของนักพัฒนาอัลกอริทึม สิ่งต่าง ๆ ชัดเจน: ฉันไม่ต้องการสนใจประสิทธิภาพการทำงานนอกเหนือจากอัลกอริทึมหลักของฉัน เว้นแต่ฉันจะต้องทำจริง ๆ ฉันไม่อยากรู้เรื่องการสลับโมเดลเข้าและออก

แน่นอนว่าเราไม่สามารถทำเช่นนั้นได้ แต่เราสามารถพยายามลดการรบกวนที่เราต้องแนะนำให้กับโค้ดอัลกอริทึมได้ สิ่งนี้ทำให้ฉันนึกถึงการแก้ไขลิงก์ของไลบรารี gevent มันแก้ไข (หลัก ๆ) ไลบรารี socket แทนที่ด้วย gevent.socket ซึ่งสามารถสลับไปยัง greenlets อื่น ๆ เมื่อ IO จะบล็อก คล้ายกับ goroutine (จริง ๆ แล้ว gevent เก่ากว่า Golang!)

เนื่องจากเราใช้ไลบรารี HuggingFace (transformers, diffusers) ในการโหลดโมเดลในขณะนั้น เป้าหมายจึงชัดเจน: เราแนะนำการเรียก monkey-patch เพียงครั้งเดียว และโค้ดที่เหลือควรยังคงไม่เปลี่ยนแปลง XXXPipeline.from_pretrained(...) ควรจะเร็วขึ้นมาก

ข้อเท็จจริงบางประการ การตัดสินใจที่ชัดเจน และสมมติฐาน

Overmind เป็นไลบรารีแคช มันแคชผลการเรียกโหลดโมเดลลงในหน่วยความจำของระบบและสร้างมันขึ้นมาใหม่อย่างรวดเร็วในภายหลัง

เราข้ามการพูดถึงวิธีการที่ monkey-patching ถูกนำไปใช้ นั่นเป็นรายละเอียดที่ไม่น่าสนใจนัก สิ่งที่เราต้องรู้คือ มันเปลี่ยนเส้นทางการเรียก XXXPipeline.from_pretrained(...) ทั้งหมดไปที่ overmind.api.load(XXXPipeline.from_pretrained, ...)

เราใช้ pickle เพื่อจัดเก็บผลลัพธ์แคชของเราเนื่องจาก... เราไม่มีทางเลือก และ torch.save เองก็ใช้ pickle มันแปลกที่จะไม่ใช้มัน

เราใช้สถาปัตยกรรม client/server เนื่องจากเราไม่ต้องการทำให้แคชของเราเป็นโมฆะเมื่อกระบวนการสิ้นสุด มีการเรียก subprocess หลายครั้งที่อาจได้รับประโยชน์จากมัน

เราสมมติว่าพารามิเตอร์ XXXPipeline.from_pretrained เป็นสิ่งที่สามารถแฮชได้ง่าย (str และสิ่งที่คล้ายกัน) และโมเดลอื่น ๆ ที่โหลดโดย overmind (อธิบายภายหลัง)

ชื่อ overmind ยืมมาจาก Starcraft ตามที่คุณอาจเดาได้

สร้างมันขึ้นมาใหม่อย่างรวดเร็ว!

เราไม่สามารถบันทึกผลลัพธ์ pickle.loads ในหน่วยความจำและเรียกมันว่าจบได้ ในสถานการณ์ที่เตรียมพร้อมแล้ว แคชหน้าเพจของ Linux ทำงานของมันในการแคชโมเดลบนดิสก์และเรายังคงเห็นเวลาในการโหลดที่วัดได้ในระดับหลายสิบวินาที

ความไม่มีประสิทธิภาพมาจากการคัดลอกหน่วยความจำ ใน Python แม้แต่การสร้างวัตถุหลายล้านชิ้นก็จะใช้เวลาไม่เกินหลายร้อยมิลลิวินาที อย่างไรก็ตาม สำหรับการคัดลอกหน่วยความจำขนาด 10GiB จะใช้เวลาครึ่งวินาที เราต้องหลีกเลี่ยงการคัดลอกหน่วยความจำให้มากที่สุด

โชคดีที่ส่วนใหญ่ของชิ้นส่วนหน่วยความจำขนาดใหญ่เป็น Torch tensors เราสามารถระบุเฉพาะพวกมันและละเลยส่วนที่เหลือได้อย่างปลอดภัย

จริง ๆ แล้ว ฉันได้รับความรู้เกี่ยวกับโครงสร้างภายในของ Torch tensor ในโค้ดการลดขณะทำการวิจัยกลไกการแชร์ tensor:

python
# คัดลอกจาก 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 โดยชี้ไปที่หน่วยความจำที่เราจัดการได้หรือไม่?

คำตอบคือใช่!

เมื่อเราดูโค้ด C++ ที่ UntypedStorage ถูกสร้างขึ้น เราสามารถพบโค้ดสั้นๆ เช่นนี้ได้อย่างง่ายดาย:

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 จากหลายสิ่ง bytes และ mmap เป็น 2 สิ่งหลักที่รองรับมัน และพวกมันคือสิ่งที่เราสนใจ

สุดท้ายนี้ เรารู้ว่าเราควรทำอะไร: สร้างฟังก์ชันที่รับวัตถุ memoryview และเปลี่ยนมันเป็น UntypedStorage โดยไม่ต้องคัดลอก ด้วยความสามารถในการสร้าง UntypedStorage จาก memoryview ข้อมูลเทนเซอร์จริงไม่จำเป็นต้องอยู่ในสตรีม 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 และเพื่อนๆ ของมัน มันถูก "ออกแบบ" มาเพื่อใช้เป็นกลไกการแชร์หน่วยความจำใช่ไหม? แต่มันมีข้อบกพร่องหลัก 2 ข้อ:

  • POSIX shm เป็นทรัพยากรที่หายาก สิ่งที่คุณสามารถใช้ได้ถูกกำหนดโดยวิธีที่ผู้ดูแลระบบกำหนดค่าระบบ ตัวอย่างที่รุนแรงแต่พบได้ทั่วไปคือคอนเทนเนอร์ Docker โดยค่าเริ่มต้นคุณมีเพียง 64MiB POSIX shm ที่ใช้งานได้
  • POSIX shm มีอายุการใช้งานนานกว่ากระบวนการของคุณ คุณต้องจัดการด้วยตัวเอง หากกระบวนการจัดการถูกฆ่าอย่างรุนแรง หรือไม่ได้จัดการอย่างระมัดระวัง วัตถุ shm อาจถูกทิ้งไว้บนระบบอย่างไม่มีกำหนด

หากคุณดูอย่างละเอียด Linux เต็มไปด้วย system calls ที่น่าสนใจ memfd_create เป็นสิ่งที่เราสนใจ: มันให้ fd ที่แสดงถึงการจัดสรรหน่วยความจำที่ไม่ระบุชื่อ คุณสามารถทำการดำเนินการไฟล์ทุกประเภทกับมัน: อ่าน, เขียน, และแน่นอน mmap หากเราสามารถแชร์ fd ได้ เราก็สามารถแชร์หน่วยความจำได้

การแชร์ fd มีวิธี 'มาตรฐาน' แต่ลึกลับในการทำ: sendmsg กับ SCM_RIGHTS เราสามารถใช้ประโยชน์จากไลบรารีเพื่อช่วยเราซ่อนรายละเอียดที่น่ากลัวของกระบวนการ sendmsg แต่เรายังคงต้องทำการประสานงานระหว่างกระบวนการเซิร์ฟเวอร์และไคลเอนต์ เราตัดสินใจใช้วิธีแฮ็กที่นี่: เพียงแค่เปิด /proc/{pidof(server)}/fd/{memfd} ที่ฝั่งไคลเอนต์ ในขณะที่ไม่เคยปิด fd ที่ฝั่งเซิร์ฟเวอร์ 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

ตามที่เราได้พูดคุยกันก่อนหน้านี้ เราจำเป็นต้องแก้ไขกระบวนการ pickling ของ UntypedStorage คล้ายกับที่ได้ดำเนินการใน torch.multiprocessing.reductions เรากำหนดฟังก์ชันลดที่กำหนดเองสำหรับ pickle:

python
# Hoarder และ borrower เป็น wrapper สำหรับ SharedMemory ข้างต้น ประกอบด้วย
# สิ่งที่น่าเบื่อเช่น memory arena เป็นต้น
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)  # Zero-copy!
    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

  1. ในระดับสูง วิธีของ PyTorch ถูกออกแบบมาสำหรับ 'การส่งเทนเซอร์ไปยัง subprocess' ดูเหมือนจะเหมือนกันแต่มีความแตกต่างเล็กน้อย
  2. PyTorch ใช้ POSIX shm เพื่อแชร์หน่วยความจำ ซึ่งอยู่ภายใต้ข้อจำกัดที่กล่าวถึงก่อนหน้านี้
  3. สำหรับทุกเทนเซอร์ (หรือ UntypedStorage) PyTorch จัดสรรวัตถุ POSIX shm เฉพาะสำหรับมัน แม้ว่ามันจะมีเพียง 4 ไบต์ แต่ละวัตถุใช้ fd
  4. PyTorch ยกเลิกการจัดสรร POSIX shm เมื่อมันถูก unpickled ทำให้ไม่เหมาะสมสำหรับความต้องการของเรา เราจำเป็นต้อง deserialize สตรีม pickle เดียวกันหลายครั้ง
  5. มีตรรกะการแชร์ที่เกี่ยวข้องกับ CUDA มากมาย ซึ่งเป็นเสียงรบกวนและปัญหาสำหรับกรณีการใช้งานของเรา

ทำไมคุณถึงบอกว่า 'ข้อมูลเทนเซอร์ถูกคัดลอกหลายครั้ง'?

สำหรับ torch.load บนดิสก์ทั่วไป:

  • ไฟล์ torch.save บนดิสก์ถูกอ่านเข้าสู่หน่วยความจำ
  • รับข้อมูล torch.UntypedStorage จริงเป็น bytes โดยการแยกไฟล์ Zip (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 เมื่อเราเริ่มต้น CUDA และ torch ในเซิร์ฟเวอร์ overmind แล้ว ไม่มีวิธีง่ายๆ ในการยกเลิกการเริ่มต้น ซึ่งอาจทำให้เกิดปัญหาสำหรับงานจริง (ส่วนใหญ่คือ VRAM ที่ใช้งานได้น้อยลง) ดังนั้นเราจึงแก้ไขเซิร์ฟเวอร์ของเราให้สร้าง subprocess โหลดเข้าไปในหน่วยความจำที่ใช้ร่วมกัน และยุติการทำงาน สิ่งนี้ช่วยปรับปรุงความเสถียรของเซิร์ฟเวอร์ 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 ที่ไม่สามารถ pickle ได้ เราต้องลบออก:

python
from accelerate.hooks import remove_hook_from_module
remove_hook_from_module(model, True)
model.__dict__.pop('to', None)  # ลบ warning monkeypatches
model.__dict__.pop('cuda', None)

เรายังพบปัญหาที่ฟังก์ชันถูกซ้อนอยู่ภายในฟังก์ชันอื่น (แทนที่จะอยู่ในระดับบนสุด) ซึ่งทำให้ไม่สามารถ picklable ได้ เราพยายามหาทางแก้ไข แต่ไม่สำเร็จ เราต้องเปลี่ยน pickle ของเราจากที่มีให้ใน stdlib เป็น dill เพื่อ pickle สิ่งนี้ dill ทรงพลังมากกว่า แต่เป็นการดำเนินการ Python ล้วนๆ ซึ่งช้ากว่าเวอร์ชันไลบรารีมาตรฐาน โชคดีที่ต้นทุนนี้จะถูกจ่ายเพียงครั้งเดียวเมื่อเราโหลดโมเดลครั้งแรก (มีผลเฉพาะกับการ pickling ไม่ใช่ unpickling)

การสนับสนุน stable-fast

stable-fast สร้างผลลัพธ์ torch.compile ซึ่งไม่สามารถ pickle ได้ แต่ด้วย torch.jit.save เราสามารถบันทึกผลลัพธ์เป็นไฟล์ zip ได้ ฟังดูไม่มีประสิทธิภาพ แต่ก็ดีกว่าไม่มีอะไรเลย

ด้วยเพียง torch.jit.save ไม่เพียงพอที่จะ pickle ผลลัพธ์ stable-fast stable-fast ใช้กระบวนการ 'flatten' เพื่อทำให้โมดูล Torch สามารถติดตามได้ เมื่อพบสิ่งที่ไม่รู้จัก (เช่น คลาส dataclass) มันจะไม่ serialize แต่จะเก็บการอ้างอิงไปยังคลาสจริงเท่านั้น เราได้แก้ไขตรรกะที่เกี่ยวข้องเพื่อเก็บคลาสที่ถูก pickle ไว้ในสตรีม 'flatten'

python
def stable_fast_quirks():
    ...

    # pickle dataclass type instead of just put it into a container (which will not survive after 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 ใหม่ด้วย ZIP_STORED ดังนั้นเราไม่จำเป็นต้องแยกไฟล์ ZIP สำหรับการโหลดครั้งถัดไป
  2. อินเตอร์เฟซ torch.jit.load ก็มีปัญหาการคัดลอกหน่วยความจำเช่นกัน ดังนั้นเราจึงเขียน wrapper ง่าย ๆ เพื่อโหลดผ่านโปรโตคอลบัฟเฟอร์ของ 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);  // No copy!
            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')

ตามที่เราได้กล่าวถึงก่อนหน้านี้ อาร์กิวเมนต์ของฟังก์ชันถูกสันนิษฐานว่าเป็นวัตถุที่ง่ายและสามารถ picklable ได้ง่าย แต่รูปแบบนี้ทำลายสมมตินั้น เพื่อจัดการกับสิ่งนี้ เราได้เพิ่มตรรกะพิเศษ: ผลลัพธ์ที่แคชแต่ละรายการจะได้รับ ID แนบ หากวัตถุนั้นถูกใช้เป็นอาร์กิวเมนต์ในการเรียกอื่น ไคลเอนต์จะแทนที่ด้วย ID ของมัน และเซิร์ฟเวอร์สามารถกู้คืนวัตถุจริงตาม ID ได้

โมเดล pipeline ที่ได้จะมีการอ้างอิงถึง vae เพื่อความเรียบง่าย เราเพียงแค่ pickle มันโดยตรงที่นี่ อย่างไรก็ตาม เมื่อย้าย UntypedStorage จริงไปยังหน่วยความจำที่ใช้ร่วมกัน เราจะลดข้อมูลที่ซ้ำกันใด ๆ

เราอาจใช้กลไก persistent_id ของ pickle แต่ฉันไม่ได้ลองเส้นทางนี้ นั่นน่าเสียดายเล็กน้อย

การวัดประสิทธิภาพ

และตอนนี้สำหรับส่วนที่ทุกคนชอบดู

เราใช้สคริปต์รูปแบบ VAE ของส่วนสุดท้ายเพื่อทำการทดสอบของเรา

การทดสอบvaedepthedgepipelineto('cuda')รวม
w/o, 1st1.180.981.411.650.916.16
w/o, 2nd1.150.960.971.650.895.66
w/o, 3rd1.150.960.981.610.915.65
w/o, 4th1.421.101.111.720.886.27
w/o, 5th1.281.081.101.720.926.13
w/, 1st5.445.175.417.290.8624.20
w/, 2nd0.000.010.010.200.871.12
w/, 3rd0.010.010.010.210.861.12
w/, 4th0.010.010.010.200.901.15
w/, 5th0.010.010.010.210.861.13

ตามที่คุณเห็น การโหลดครั้งแรกด้วย overmind ใช้เวลา 24.2 วินาที ซึ่งนานกว่าการโหลดโดยไม่มีมันอย่างมาก อย่างไรก็ตาม ในการโหลดครั้งต่อไป ค่าใช้จ่ายของ .to('cuda') ยังคงมีอยู่

เมื่อรวมขนาดของไฟล์โมเดลที่ถูกทำให้เป็นซีเรียลทั้งหมดแล้ว คาดว่าทั้ง pipeline จะใช้หน่วยความจำประมาณ 5808 เมกะไบต์ การทดสอบเบื้องต้นให้ผลลัพธ์ที่คล้ายกัน

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 การใช้งานหน่วยความจำของระบบลดลงอย่างมาก เราไม่ได้ประสบปัญหาการขาดแคลนหน่วยความจำของระบบ แต่ถ้าเราเคยประสบ นี่จะเป็นชัยชนะที่ยิ่งใหญ่

ต่อมา เราพบว่ามันเป็นการเพิ่มประสิทธิภาพที่ยอดเยี่ยมสำหรับนักพัฒนาอัลกอริทึมและ pipeline ของเรา สำหรับแต่ละวงจรการแก้ไข-ตรวจสอบ เราสามารถประหยัดเวลาในการโหลดได้ 10 ถึง 20 วินาที ซึ่งสามารถเพิ่มขึ้นเป็นจำนวนมากได้ ที่สำคัญกว่านั้น วินาทีที่ประหยัดได้สามารถทำให้นักพัฒนาอยู่ในโฟลว์ได้

Github

เราเปิดซอร์สมันบน Github เราจะยินดีถ้ามันช่วยได้

ดูว่า Inference ที่เร็วขึ้นทำให้เกิดอะไรได้บ้าง
Overmind เป็นพลังเบื้องหลังความเร็วของการสร้าง AI 3D ของ Meshy ลองใช้และดูผลลัพธ์ด้วยตัวคุณเอง
โพสต์นี้มีประโยชน์หรือไม่?

3D, ตามคำสั่ง

ติดต่อฝ่ายขาย