本地部署大模型启动慢:先测盘,别急着换显卡

📌 本文部分内容由 AI 辅助整理,已经人工核对。文中所有带单位的数都来自我在自己机器上真跑的脚本,脚本全文在第十节,零依赖、标准库就能跑,你可以原样复制下来在自己的盘上验一遍。引用的两处外部资料(Linux open(2) 手册、llama.cpp 的加载参数)我抓了原文,逐字贴在下面。

有人问我,为什么同样一台机器,早上第一次起服务要等好几分钟,下午再起就几秒钟。他怀疑是显卡有问题。

不是显卡的事。

这台机器上我量了一下:同一个文件,绕开缓存读是 109.30 MiB/s,命中缓存读是 4.35 GiB/s,差 40.7 倍。早上那次付的是第一个价,下午那次付的是第二个价。显卡在这两次里干的活一模一样。

这篇把这条链路拆开算一遍。结论先放这儿:在很多本地部署的场景里,第一个撞到的瓶颈不在算力,在存储。 而这一层多数人根本没量过。


一、先把两件事分开

「慢」这个字底下压着两件完全不同的事:

  • 等多久才开始吐第一个字 —— 权重要从盘读出来、进内存、进显存。这是存储的事。
  • 开始吐之后每秒几个字 —— 每生成一个 token 要把权重过一遍。这是显存带宽的事。

这两件事的瓶颈不在一个地方,优化手段也完全不一样。换显卡只解决第二件。 如果你的痛点是「点了启动然后去接了杯水回来还在转」,换卡一点用都没有。

本文只讲第一件。


二、权重从盘到内存,中间隔着一个 page cache

Linux 会把读过的文件页留在内存里。下次再读同一个文件,直接从内存给你,根本不碰盘。这就是「第二次快」的全部原因。

想量出盘的真实速度,就得绕开它。open(2) 手册里给了办法:

O_DIRECT (since Linux 2.4.10)
       Try to minimize cache effects of the I/O to and from this
       file.  In general this will degrade performance, but it is
       useful in special situations, such as when applications do
       their own caching.  File I/O is done directly to/from user-
       space buffers.

—— 来源:man 2 open(man7.org 在线版原文)

注意它自己写的那句 In general this will degrade performance。O_DIRECT 不是用来加速的,是用来测得准的。加了它,数据必须真的从块设备上来。

我的脚本就靠这个拿冷读的数:

==================================================================
一、这台机器的顺序读带宽
==================================================================
  冷读 (O_DIRECT,绕开 page cache) : 109.30 MiB/s
  热读 (命中 page cache)           : 4.35 GiB/s
  热读 / 冷读                       : 40.7 倍

先说清楚我这台机器是什么:云上的一台容器,数据盘是共享卷。 109 MiB/s 这个数放在物理 NVMe 上是不正常的,放在共享卷上很正常。你在自己机器上跑,数会完全不同 —— 这正是要你自己跑一遍的原因。

我连着跑了三次,冷读分别是 107.68 / 88.60 / 109.30 MiB/s。抖动接近两成。共享存储就是这样,单次测不准,多跑几次看区间。

慢会不会是我块开小了?

这是我第一反应怀疑的。O_DIRECT 单线程读,队列深度是 1,块开太小确实会被延迟吃掉。所以我扫了一遍块大小:

  块大小扫描(确认慢不是因为你块开小了):
       128 KiB   97.03 MiB/s
       512 KiB  108.27 MiB/s
      1024 KiB  112.28 MiB/s
      4096 KiB  102.74 MiB/s
     16384 KiB  105.03 MiB/s
    区间 97.03 MiB/s ~ 112.28 MiB/s,1.2 倍

从 128 KiB 扫到 16 MiB,带宽只在 1.2 倍的区间里晃,没有量级变化。块大小不是原因,这条链路本身就这么快。

这一步别省。你要是不扫,很容易把一个「测法问题」当成「硬件问题」,然后去买一块并不解决问题的盘。

同时也要承认这个数的边界:它是单线程、队列深度 1 测出来的。真实的加载器可能有预读、可能并发读,拿到的会比这个高。所以把它当下界看,不要当上限。


三、拿你自己的带宽算一遍

带宽有了,剩下的就是除法。权重文件多大,取决于参数量和每个参数占几个字节:

参数量  FP16 / BF16   INT8 / Q8_0   Q5_K_M 附近   Q4_K_M 附近
------  ------------  ------------  ------------  -----------
7B      2 分 02 秒    1 分 01 秒    39.7 秒       33.6 秒
14B     4 分 04 秒    2 分 02 秒    1 分 19 秒    1 分 07 秒
32B     9 分 18 秒    4 分 39 秒    3 分 01 秒    2 分 34 秒
70B     20 分 21 秒   10 分 11 秒   6 分 37 秒    5 分 36 秒
235B    1 小时 08 分  34 分 10 秒   22 分 13 秒   18 分 48 秒
671B    3 小时 15 分  1 小时 37 分  1 小时 03 分  53 分 40 秒

这是按 109.30 MiB/s 算的,换成你的带宽,整张表按比例缩放

看最后一行。671B 的 FP16 权重,在这条链路上光是把它读一遍就要三个多小时。这不是模型的问题,也不是卡的问题,是一个除法的问题。

再看一张横向的,同一个 70B / Q4 量级的模型(权重约 35.86 GiB)在不同介质上:

介质               顺序读        加载耗时
-----------------  ------------  ----------
本机实测(冷读)     109.30 MiB/s  5 分 36 秒
机械硬盘 7200rpm   171.66 MiB/s  3 分 34 秒
SATA SSD           524.52 MiB/s  1 分 10 秒
NVMe Gen3          3.26 GiB/s    11.0 秒
NVMe Gen4          6.52 GiB/s    5.5 秒
千兆网 NAS(1GbE)   112.53 MiB/s  5 分 26 秒
万兆网 NAS(10GbE)  1.07 GiB/s    33.5 秒

介质那一列是厂商标称量级,只作横向参照,不是我实测的。但这张表能回答一个很实际的问题:从 5 分 36 秒到 11 秒,中间隔的不是一块更贵的显卡,是一块 NVMe。

还有一行值得多看一眼:千兆网的 NAS,112 MiB/s,跟机械盘一个量级。 很多人把模型放在千兆网的共享存储上,然后奇怪为什么加载这么慢 —— 那根网线就是天花板,盘再快也出不来。


四、「加载完了」可能是假象

这一步最容易骗人。

现在的加载器多数默认用 mmap,把权重文件映射进地址空间,并不立刻读。页是在第一次被访问的时候才真正从盘上来的。所以你会看到一个很奇怪的现象:进度条唰一下就满了,但第一次提问卡了很久。 时间没有消失,它只是从「加载」挪到了「第一次推理」。

llama.cpp 把这几种加载方式做成了一个参数,原文是这样写的:

{"-lm", "--load-mode"}, "MODE",
"model loading mode (default: mmap)\n"
"- none: no special loading mode\n"
"- mmap: memory-map model (if mmap disabled, slower load but may reduce pageouts if not using mlock)\n"
"- mlock: force system to keep model in RAM rather than swapping or compressing\n"
"- mmap+mlock: mmap + force system to keep model in RAM rather than swapping or compressing\n"
"- dio: use DirectIO if available\n"

—— 来源:llama.cpp 仓库 common/arg.cpp,我抓的是 master 分支当前内容。同一处代码里还有一行弃用提示:--mmap / --no-mmap / --direct-io 这些老参数正在被 --load-mode 取代,两套混用只有最后一个生效。

默认是 mmap。 也就是说,如果你没特意改过,你测到的「加载时间」大概率不是完整的读盘时间。

想看到真实成本,两个办法:要么用 mlock 那档,强制它真的把权重压进内存不许换出;要么直接看 dio,让它走 DirectIO。对照着跑一次,差出来的那段时间就是之前被藏起来的。


五、现象 → 多半是什么 → 怎么确认

把上面几节压成一张排查表。左边是你看到的,中间是我的判断,右边是别信我,自己验的方法。

现象多半是什么怎么确认
第一次几分钟,第二次几秒page cache 命中跑脚本比冷读/热读,看倍数
换了个模型又变慢了缓存被新文件挤掉了比一下可用内存和模型总大小
显卡很新,启动照样慢瓶颈在盘不在卡测顺序读,套第三节的表
加载「秒完」但第一次回答很卡mmap 惰性加载,成本挪到了首次推理--load-mode mlockdio 对照
盘标称很快,实测很慢共享卷 / 虚拟化 / 网络存储先扫块大小排除测法,再看是不是网络盘
多节点比单节点还慢权重分发走了网络看网卡速率,以及每个节点是各拉一份还是共享一份
换了 NVMe 但没快多少瓶颈已经不在这一层了重测冷读,若已达标就去查框架初始化和显存拷贝

六、权重只是小头

到这儿为止说的都是权重。但如果你做的是知识库、检索、文档问答这类事,权重根本不是占地方的大头。

一个 70B 的 Q4 权重是 35 GiB 左右。而企业真正要喂进去的东西 —— 历年的合同、图纸、工单、会议录音、监控录像 —— 动辄几个 TB 到几十 TB。这些数据要落盘、要建索引、要被反复读。

这就带来两个和上面完全不同的问题:

  1. 容量的量级不一样。 权重是几十 GiB,语料是几十 TiB,差三个数量级。按权重规划的存储,装不下语料。
  2. 读法不一样。 权重是顺序读,一次读完;检索是随机读,大量小请求。顺序带宽再高,随机 IOPS 不行照样卡。我这个脚本只量了顺序读,量不了这个。 别拿它的结论去推检索性能。

我见过的返工,多数不是「模型选错了」,是一开始按模型的尺寸规划了存储,跑通之后才发现数据放不下


七、到了多节点,这件事会被放大

单机的时候,权重读一遍就完事。多节点就不一样了:

  • 每个节点各存一份?存储成本乘以节点数,而且更新模型要同步 N 份。
  • 放共享存储上,各节点从那儿拉?那么拉取要走网络,前面那张表里「千兆网 112 MiB/s」的行就会重演一次,只是这次要乘以节点数。

于是问题的形状变了。单机的时候你在选一块盘;多节点的时候你在选一个「权重放哪儿、谁负责发给谁」的结构。 这已经不是买块 NVMe 能解决的了。


八、那该找什么样的机器

这一节是判断,不是实测,我把话说在前面。

顺着上面几节的逻辑推下来,如果你确认自己卡在存储和分发这一层,那你要找的不是一台更强的算力机,而是一个把三件事合在一起的角色:

  • 容量按语料规划,不是按权重规划 —— 前面算过,两者差三个数量级。
  • 它自己就是共享存储 —— 权重和语料放它上面,算力节点从它取,而不是每台各存一份。
  • 它和算力节点之间是高速直连 —— 不然第七节那个网络瓶颈会原样搬过来。这里的关键是链路,不是盘。

也就是说,在「一堆算力节点」之外,你还需要一个承担存储与调度的枢纽节点。这类形态现在做得到桌面尺寸,不必是一整个机柜。

我这么说不是因为它更时髦。是因为这三条要求单独买任何一样都满足不了:买盘解决不了分发,买交换机解决不了容量,买卡跟这一层完全无关。


九、什么时候不该在存储上花钱

写完上面一节我得把话说回来,不然这篇就成软文了。下面这些情况,你不该动存储:

  1. 你的模型能常驻内存。 如果机器内存装得下权重,而且服务是长期开着的,那读盘只发生一次。第一次等 5 分钟,之后几个月都不再付这个成本 —— 这不值得为它换硬件。
  2. 你的痛点是「吐字慢」不是「等得久」。 回到第一节,这两件事的瓶颈不在一个地方。这种情况下换盘一点用没有。
  3. 你只跑 7B 以下。 看第三节那张表,7B 的 Q4 在我这条最慢的链路上也就 33.6 秒。33 秒不值得为它做架构改造。
  4. 你还没量过。 这是最重要的一条。先跑一遍脚本再决定,别因为看了这篇就去买盘。我给的是方法,不是结论 —— 你的瓶颈在哪一层,只有你自己的数说了算。

第 4 条我想多说一句。上面每一张表我都给了脚本、给了口径、说了它测不到什么,就是为了让你能推翻我。要是你跑出来冷读有 3 GiB/s,那这篇文章对你就是没用的,这很正常。


十、脚本

零依赖,标准库跑得动就行。默认在当前目录造一个 2 GiB 的临时文件,跑完自己删。

python3 model_load_budget.py                 # 默认 2 GiB
python3 model_load_budget.py --size-gb 8     # 文件越大冷读越准
python3 model_load_budget.py --dir /mnt/nvme # 指定测哪块盘
python3 model_load_budget.py --sweep         # 扫块大小,排除测法问题
python3 model_load_budget.py --json          # 机器可读
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
model_load_budget.py —— 把「本地跑大模型,启动要等多久」这件事算清楚。

它做三件事:
  1. 实测你这台机器的顺序读带宽。分两个数:
     - 冷读:用 O_DIRECT 绕开 page cache,逼数据真的从盘上来
     - 热读:普通读,命中 page cache
     这两个数常常差一个数量级,而「第二次快了」正是最容易骗到人的地方。
  2. 用实测带宽,算不同规模 / 不同量化的权重文件要多久才能进内存。
  3. 给一张按介质档位的速查表,方便你判断该不该换盘。

零依赖,只用标准库。默认在当前目录建一个 2 GiB 临时文件,跑完自己删。

  python3 model_load_budget.py                 # 默认 2 GiB 测试文件
  python3 model_load_budget.py --size-gb 8     # 文件越大,冷读越接近真实值
  python3 model_load_budget.py --dir /mnt/nvme # 指定要测哪块盘
  python3 model_load_budget.py --json          # 机器可读输出

注意:脚本量的是「顺序读带宽」,不是随机 IOPS。加载模型权重是典型顺序读,
所以这个数是对的;但如果你的瓶颈在向量库检索,那要量的是另一个东西(见文末)。
"""

import argparse
import json
import mmap
import os
import sys
import time

BLOCK = 1 << 20          # 1 MiB 对齐块,O_DIRECT 要求对齐
ALIGN = 4096


def human(n):
    """字节数转人话。"""
    for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'):
        if abs(n) < 1024 or unit == 'TiB':
            return '%.2f %s' % (n, unit)
        n /= 1024.0


def hms(seconds):
    """秒数转人话,够短就只说秒。"""
    if seconds < 60:
        return '%.1f 秒' % seconds
    m, s = divmod(seconds, 60)
    if m < 60:
        return '%d 分 %02.0f 秒' % (m, s)
    h, m = divmod(m, 60)
    return '%d 小时 %02d 分' % (h, m)


def make_file(path, size_bytes):
    """造一个测试文件。写完 fsync,确保真落盘而不是停在页缓存里。"""
    buf = os.urandom(BLOCK)          # 用随机数据,避免文件系统压缩/去重把测试做废
    written = 0
    with open(path, 'wb') as f:
        while written < size_bytes:
            n = min(BLOCK, size_bytes - written)
            f.write(buf[:n])
            written += n
        f.flush()
        os.fsync(f.fileno())
    return written


def read_direct(path, size_bytes, block=BLOCK):
    """冷读:O_DIRECT 绕过 page cache,数据必须从块设备上来。

    注意这是单线程、队列深度 1 的读法。真实的加载器可能有预读或并发,
    拿到的数会比这个高。所以这个值是一条**保守下界**,不是盘的极限值。
    """
    flags = os.O_RDONLY | getattr(os, 'O_DIRECT', 0)
    fd = os.open(path, flags)
    # O_DIRECT 要求用户态缓冲区地址对齐,mmap 匿名映射天然页对齐
    buf = mmap.mmap(-1, block)
    total = 0
    t0 = time.perf_counter()
    try:
        while True:
            n = os.preadv(fd, [buf], total) if hasattr(os, 'preadv') else None
            if n is None:                      # 老内核退路
                os.lseek(fd, total, os.SEEK_SET)
                n = os.readv(fd, [buf])
            if n <= 0:
                break
            total += n
    finally:
        elapsed = time.perf_counter() - t0
        os.close(fd)
        buf.close()
    return total, elapsed


def read_cached(path):
    """热读:普通读。刚写完/刚读过,大概率命中 page cache。"""
    total = 0
    t0 = time.perf_counter()
    with open(path, 'rb') as f:
        while True:
            b = f.read(BLOCK)
            if not b:
                break
            total += len(b)
    return total, time.perf_counter() - t0


# 参数量 × 每参数字节数 → 权重文件大小
# 说明:这里算的是权重本身,不含 KV Cache、不含框架开销。
QUANTS = [
    ('FP16 / BF16', 2.0),
    ('INT8 / Q8_0', 1.0),
    ('Q5_K_M 附近', 0.65),
    ('Q4_K_M 附近', 0.55),
]

MODEL_SIZES_B = [7, 14, 32, 70, 235, 671]

# 常见介质的顺序读带宽档位(厂商标称量级,用于横向参照,不是本机实测)
MEDIA = [
    ('机械硬盘 7200rpm', 180e6),
    ('SATA SSD', 550e6),
    ('NVMe Gen3', 3.5e9),
    ('NVMe Gen4', 7.0e9),
    ('千兆网 NAS(1GbE)', 118e6),
    ('万兆网 NAS(10GbE)', 1.15e9),
]


def table(rows, headers):
    """按显示宽度对齐(中文算两格),别用 len()。"""
    def w(s):
        return sum(2 if ord(c) > 0x2E80 else 1 for c in str(s))
    widths = [max(w(h), max((w(r[i]) for r in rows), default=0)) for i, h in enumerate(headers)]
    line = '  '.join(h + ' ' * (widths[i] - w(h)) for i, h in enumerate(headers))
    out = [line, '  '.join('-' * x for x in widths)]
    for r in rows:
        out.append('  '.join(str(c) + ' ' * (widths[i] - w(c)) for i, c in enumerate(r)))
    return '\n'.join(out)


def main():
    ap = argparse.ArgumentParser(description='算清本地跑大模型的存储这一层')
    ap.add_argument('--size-gb', type=float, default=2.0, help='测试文件大小 GiB,默认 2')
    ap.add_argument('--dir', default='.', help='在哪块盘上测,默认当前目录')
    ap.add_argument('--keep', action='store_true', help='跑完保留测试文件')
    ap.add_argument('--sweep', action='store_true',
                    help='扫一遍块大小,确认冷读慢不是你块开小了')
    ap.add_argument('--json', action='store_true', help='输出 JSON')
    a = ap.parse_args()

    size = int(a.size_gb * (1 << 30))
    path = os.path.join(a.dir, '_model_load_probe.bin')

    if not a.json:
        print('\n在 %s 上造一个 %s 的测试文件…' % (os.path.abspath(a.dir), human(size)))
    made = make_file(path, size)

    try:
        try:
            cold_bytes, cold_t = read_direct(path, made)
            cold_bw = cold_bytes / cold_t
            direct_ok = True
        except OSError as e:
            # 有些文件系统(tmpfs、部分网络盘、overlayfs)不支持 O_DIRECT
            cold_bw, direct_ok = None, False
            cold_err = '%s: %s' % (type(e).__name__, e)

        warm_bytes, warm_t = read_cached(path)
        warm_bw = warm_bytes / warm_t

        sweep = []
        if a.sweep and direct_ok:
            for kb in (128, 512, 1024, 4096, 16384):
                try:
                    b, t = read_direct(path, made, block=kb << 10)
                    sweep.append((kb, b / t))
                except OSError:
                    pass
    finally:
        if not a.keep and os.path.exists(path):
            os.remove(path)

    result = {
        'dir': os.path.abspath(a.dir),
        'file_size_bytes': made,
        'cold_read_bytes_per_s': cold_bw,
        'warm_read_bytes_per_s': warm_bw,
        'o_direct': direct_ok,
    }

    if a.json:
        result['load_seconds'] = {}
        base = cold_bw or warm_bw
        for p in MODEL_SIZES_B:
            for qname, bpp in QUANTS:
                result['load_seconds']['%dB/%s' % (p, qname)] = p * 1e9 * bpp / base
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return

    print('\n' + '=' * 66)
    print('一、这台机器的顺序读带宽')
    print('=' * 66)
    if direct_ok:
        print('  冷读 (O_DIRECT,绕开 page cache) : %s/s' % human(cold_bw))
    else:
        print('  冷读 : 本文件系统不支持 O_DIRECT(%s)' % cold_err)
        print('         下面一律按热读算,会明显偏乐观,别拿它做采购决策。')
    print('  热读 (命中 page cache)           : %s/s' % human(warm_bw))
    if direct_ok and cold_bw:
        print('  热读 / 冷读                       : %.1f 倍' % (warm_bw / cold_bw))
        print('\n  这个倍数就是「第一次慢、第二次快」的全部原因。')
        print('  你重启机器、或者换一个模型把缓存挤掉之后,付的是冷读那个价。')

    if sweep:
        print('\n  块大小扫描(确认慢不是因为你块开小了):')
        for kb, bw in sweep:
            print('    %6d KiB  %s/s' % (kb, human(bw)))
        lo = min(b for _, b in sweep)
        hi = max(b for _, b in sweep)
        print('    区间 %s/s ~ %s/s,%.1f 倍' % (human(lo), human(hi), hi / lo))
        print('    没有量级变化 = 瓶颈不在块大小,在这条链路本身。')

    base = cold_bw or warm_bw
    label = '冷读' if direct_ok else '热读(偏乐观)'

    print('\n' + '=' * 66)
    print('二、按本机%s带宽,权重从盘进内存要多久' % label)
    print('=' * 66)
    rows = []
    for p in MODEL_SIZES_B:
        row = ['%dB' % p]
        for qname, bpp in QUANTS:
            nbytes = p * 1e9 * bpp
            row.append(hms(nbytes / base))
        rows.append(row)
    print(table(rows, ['参数量'] + [q[0] for q in QUANTS]))
    print('\n  只算权重读盘,不含显存拷贝、不含框架初始化、不含 KV Cache。')
    print('  所以这是一条下界:真实启动时间只会比它长。')

    print('\n' + '=' * 66)
    print('三、换介质能换来什么(厂商标称量级,横向参照用)')
    print('=' * 66)
    demo = 70, 0.55           # 拿 70B / Q4_K_M 当标尺
    nbytes = demo[0] * 1e9 * demo[1]
    rows = [['本机实测(%s)' % label, human(base) + '/s', hms(nbytes / base)]]
    for name, bw in MEDIA:
        rows.append([name, human(bw) + '/s', hms(nbytes / bw)])
    print('  标尺:%dB 模型,%s,权重约 %s\n' % (demo[0], QUANTS[3][0], human(nbytes)))
    print(table(rows, ['介质', '顺序读', '加载耗时']))

    print('\n' + '=' * 66)
    print('四、这个脚本没量的东西')
    print('=' * 66)
    print('  · 随机读 / IOPS —— 向量库检索、大量小文件是随机读,跟顺序带宽是两回事')
    print('  · 网络存储的抖动 —— NAS/共享盘的尾延迟比平均带宽更能决定体感')
    print('  · 多节点分发 —— 一份权重要进 N 台机器时,瓶颈会从盘转到网')
    print('  · 显存带宽 —— 那决定「每秒吐几个 token」,跟本文的「等多久才开始吐」无关')
    print()


if __name__ == '__main__':
    sys.exit(main())

十一、这套方法不解决什么

说清楚边界,免得当银弹用:

  1. 只量顺序读。 向量检索、大量小文件是随机读,跟顺序带宽是两回事,本脚本量不了。
  2. 只量单线程、队列深度 1。 真实加载器可能并发读、可能预读,实际会比这个快。这是下界不是上限。
  3. 没量网络存储的抖动。 NAS 和共享盘的尾延迟常常比平均带宽更决定体感,而平均值会把尾巴抹平。
  4. 没算显存拷贝和框架初始化。 从内存到显存那一段、以及框架自己的启动开销,都不在这条时间线里。所以真实启动时间只会比表里的长。
  5. 共享卷上的数会抖。 我三次冷读差了两成。单次读数不要当结论,多跑几次看区间。

小结

  1. 「启动慢」和「吐字慢」是两件事,瓶颈不在一个地方,换显卡只解决后一件。
  2. 冷读和热读能差一个数量级 —— 我这台机器是 40.7 倍。「第二次快」是缓存,不是问题解决了。
  3. 想测准就得绕开 page cache,O_DIRECT 就是干这个的,手册原话是它会让性能变差、但适用于「应用自己做缓存」这类场景。
  4. 测出慢之后先扫块大小,排除是自己测法的问题。我从 128 KiB 扫到 16 MiB 只差 1.2 倍,说明瓶颈是链路本身。
  5. 加载时间 = 权重大小 ÷ 冷读带宽。这个除法能一路算到 671B,也能反过来告诉你换介质能省多少。
  6. 默认的 mmap 是惰性的,「加载完了」不等于「读完了」,时间可能被挪到了首次推理。
  7. 做知识库的话,权重是小头,语料才是大头,而且它是随机读,另一套账。
  8. 到多节点,问题从「选一块盘」变成「选一个权重放哪儿、谁发给谁的结构」。

最后留一个我确实没算明白的问题:多节点的时候,一份权重到底该放哪儿。

我知道的有三条路,代价各不相同:

一、每个节点各存一份全量权重。 最简单,没有单点,任何一台挂了不影响别人。代价是存储成本乘以节点数,而且每次换模型要同步 N 份,同步期间版本还可能不一致。

二、共享一份,由一个节点统一存放并分发。 省存储、换模型只改一处。代价是多了一个所有人都依赖的角色,它慢或者它挂了是全局的事。

三、放对象存储,节点按需拉取并本地缓存。 弹性最好,加节点不用预先规划容量。代价是首次拉取慢、缓存命中率不好预测,而且你要多运维一套东西。

这三条我都见过有人选,在各自的规模下都是对的。分界点在哪儿,我没有像样的推导 —— 它同时取决于节点数、模型换得勤不勤、以及你能不能接受一个全局依赖。我自己目前是按节点数拍的:几台以内倾向第二条,再多就不确定了。这个「几台」是经验,不是算出来的。

这个分界点我一直想算清楚,但一直没找到能算的方法 —— 找到的资料要么只讲某一条路怎么实现,要么直接给结论不给推导。这一块我目前是空的。

更多推荐