私有化部署大模型能够有效保护数据隐私、便于开展大模型安全研究和知识蒸馏。目前主流部署方式包括纯 GPU、CPU/GPU 混合以及纯 CPU 三种部署方式。本文介绍了我们针对 DeepSeek 大模型纯 CPU 本地化部署的推理探索与实践方案。我们以约 3.8 万元的整体成本,基于 llama.cpp 框架,经过硬件选型与量化精度的综合考量,实现了 q8 精度下 7.17 tokens/s 的峰值输出速度。通过散热方案改进、BIOS 参数优化及系统内存带宽调优,我们在 q8 精度下取得了不小的性能提升,其中长文本生成速度提升约 25%、峰值输出速度提升约 15%、预填充速度提升约 20%。全文内容共分为《装机选型篇》《软硬件配置篇》《性能测试与量化对比篇》《性能优化分析篇》四个部分,本篇文章涵盖前三个部分,第四部分将在下一篇文章中详细展开。

0x01 装机选型篇

装机配置推荐清单:

  • 主板:MZ73-LM1(7400 元,比较容易买到,双路当单路用)或 MZ33-AR1(5950 元)
  • CPU:单颗EPYC 9135(7900,比较容易买到) 或 EPYC 9115(5400 元)
  • 内存:DDR5 5600MHz 64GB x 12 (22800 元)
  • 硬盘:大于 1TB 的 SSD
  • 电源:850W 电源
  • 机箱:支持 ETAX 服务器主板的开放式机箱
  • 散热:纯铜内存散热马甲,内存供电mos热管散热器
  • 总成本:38000元(5200 美元) ± 5%

整机效果图:

选型思路分享:

  • 预算投入的优先级为“内存带宽” > “CPU 核心数” > “SSD 读写速度”> “CPU 主频”
  • 内存带宽直接影响生成速度
  • CPU 核心数影响预填充和并发输出速度,实测升级48及以上物理核心的CPU预填充速度可以达到50+tokens/s,最大并发输出速度可以达到40+tokens/s
  • SSD 读写速度硬性模型加载速度和prompt cache读写速度
  • CPU 主频对性能影响较小,可以选择同档次 CPU 里主频最低的获得最高性价比

若想改配置需要注意的事项:

  • 不推荐双路 CPU 方案,因为双路 NUMA 节点的跨节点访问会导致内存带宽严重劣化,而所有优化 NUMA 访存的方案都会消耗宝贵的内存容量
  • 12 个内存通道必须插满,以充分利用 CPU 所支持的全部带宽
  • 单根内存条强烈建议选择64GB,因为 12 路 64GB 共 768GB 总容量装下q8 量化后的模型权重后,剩下的存储空间做为 kv cache 还能支持22K的模型上下文
  • 主板选择的时候不要选择支持2DPC(2 DIMMs Per Channel)内存插槽的主板,即使使用这类主板也要确保每个通道只插一根内存,否则主板会对该通道进行降频如 5600MHz 降到 4800MHz,从而导致总体带宽大幅下降,使得生成速度下降 1 个 token 左右
  • CPU 和南桥的散热不重要,CPU使用风冷即可,但内存的散热非常重要,长时间内存过热可能会导致降频,内存降频后会损失高达 20% 的生成速度

功耗:

采用装机配置推荐清单中MZ73-LM1 + 9135的配置,测量在模型推理不同阶段的功耗如下:

上图中,左上为待机功耗,右上为模型加载阶段功耗,左下为模型预填充阶段功耗,右下为模型生成阶段功耗

0x02 软硬件配置篇

散热优化:

由于满载推理时内存一直高负荷运行,内存供电 mos 管和内存条本身的散热压力较大。实测给内存供电 mos 管换了热管散热器后可将 mos 管温度压制到 40 度左右,给内存条安装上纯铜散热马甲后内存颗粒表面温度可以从 70 多度的降至约60 度。散热优化后由于避免了内存过热而导致的自动降频,使得跑长文本输出时的速度得到20%的提升。散热优化后使用红外温枪测得的 mos 管温度和内存颗粒表面温度如下图所示:

BIOS优化:

由于CPU 和主板均支持 6000MHz,因此可以对内存进行小幅超频,获得保证系统稳定运行下的最大化性价比。将频率从默认频率 5600MHz 提升到 6000MHz。超频选择的入口位置:AMD CBS -> UMC Common Options -> Enforce PDR -> Memory Target Speed -> DDR6000,如下图所示。

超频后可小幅提高峰值生成速度约 0.2 个 token/s 左右。

系统优化:

  • 下载 llama.cpp 源码:
    1
    2
    git clone https://github.com/ggml-org/llama.cpp.git
    git checkout 20a9b8f5e1380243ed03aeb50ae1bf94b8d68501
  • 用下面的代码替换掉 src 目录下的llama-mmap.cpp 文件里的内容
    点击展开代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    451
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    532
    533
    534
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    548
    549
    550
    551
    552
    553
    554
    555
    556
    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    628
    629
    630
    631
    632
    633
    634
    635
    636
    637
    638
    639
    640
    641
    642
    643
    644
    645
    646
    647
    648
    649
    #include "llama-mmap.h"

    #include "llama-impl.h"

    #include "ggml.h"

    #include <cstring>
    #include <climits>
    #include <stdexcept>
    #include <cerrno>

    #ifdef __has_include
    #if __has_include(<unistd.h>)
    #include <unistd.h>
    #if defined(_POSIX_MAPPED_FILES)
    #include <sys/mman.h>
    #include <fcntl.h>
    #endif
    #if defined(_POSIX_MEMLOCK_RANGE)
    #include <sys/resource.h>
    #endif
    #endif
    #endif

    #if defined(_WIN32)
    #define WIN32_LEAN_AND_MEAN
    #ifndef NOMINMAX
    #define NOMINMAX
    #endif
    #include <windows.h>
    #ifndef PATH_MAX
    #define PATH_MAX MAX_PATH
    #endif
    #include <io.h>
    #endif

    // TODO: consider moving to llama-impl.h if needed in more places
    #if defined(_WIN32)
    static std::string llama_format_win_err(DWORD err) {
    LPSTR buf;
    size_t size = FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
    NULL, err, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPSTR)&buf, 0, NULL);
    if (!size) {
    return "FormatMessageA failed";
    }
    std::string ret(buf, size);
    LocalFree(buf);
    return ret;
    }
    #endif

    // llama_file

    struct llama_file::impl {
    #if defined(_WIN32)
    HANDLE fp_win32;
    std::string GetErrorMessageWin32(DWORD error_code) const {
    std::string ret;
    LPSTR lpMsgBuf = NULL;
    DWORD bufLen = FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
    NULL, error_code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPSTR)&lpMsgBuf, 0, NULL);
    if (!bufLen) {
    ret = format("Win32 error code: %lx", error_code);
    } else {
    ret = lpMsgBuf;
    LocalFree(lpMsgBuf);
    }

    return ret;
    }

    impl(const char * fname, const char * mode) {
    fp = ggml_fopen(fname, mode);
    if (fp == NULL) {
    throw std::runtime_error(format("failed to open %s: %s", fname, strerror(errno)));
    }
    fp_win32 = (HANDLE) _get_osfhandle(_fileno(fp));
    seek(0, SEEK_END);
    size = tell();
    seek(0, SEEK_SET);
    }

    size_t tell() const {
    LARGE_INTEGER li;
    li.QuadPart = 0;
    BOOL ret = SetFilePointerEx(fp_win32, li, &li, FILE_CURRENT);
    if (!ret) {
    throw std::runtime_error(format("read error: %s", GetErrorMessageWin32(GetLastError()).c_str()));
    }

    return li.QuadPart;
    }

    void seek(size_t offset, int whence) const {
    static_assert(SEEK_SET == FILE_BEGIN, "SEEK_SET != FILE_BEGIN");
    static_assert(SEEK_CUR == FILE_CURRENT, "SEEK_CUR != FILE_CURRENT");
    static_assert(SEEK_END == FILE_END, "SEEK_END != FILE_END");

    LARGE_INTEGER li;
    li.QuadPart = offset;
    BOOL ret = SetFilePointerEx(fp_win32, li, NULL, whence);
    if (!ret) {
    throw std::runtime_error(format("read error: %s", GetErrorMessageWin32(GetLastError()).c_str()));
    }
    }

    void read_raw(void * ptr, size_t len) const {
    size_t bytes_read = 0;
    while (bytes_read < len) {
    size_t chunk_size = std::min<size_t>(len - bytes_read, 64*1024*1024);
    DWORD chunk_read = 0;
    BOOL result = ReadFile(fp_win32, reinterpret_cast<char*>(ptr) + bytes_read, chunk_size, &chunk_read, NULL);
    if (!result) {
    throw std::runtime_error(format("read error: %s", GetErrorMessageWin32(GetLastError()).c_str()));
    }
    if (chunk_read < chunk_size || chunk_read == 0) {
    throw std::runtime_error("unexpectedly reached end of file");
    }

    bytes_read += chunk_read;
    }
    }

    uint32_t read_u32() const {
    uint32_t val;
    read_raw(&val, sizeof(val));
    return val;
    }

    void write_raw(const void * ptr, size_t len) const {
    size_t bytes_written = 0;
    while (bytes_written < len) {
    size_t chunk_size = std::min<size_t>(len - bytes_written, 64*1024*1024);
    DWORD chunk_written = 0;
    BOOL result = WriteFile(fp_win32, reinterpret_cast<char const*>(ptr) + bytes_written, chunk_size, &chunk_written, NULL);
    if (!result) {
    throw std::runtime_error(format("write error: %s", GetErrorMessageWin32(GetLastError()).c_str()));
    }
    if (chunk_written < chunk_size || chunk_written == 0) {
    throw std::runtime_error("unexpectedly failed to write bytes");
    }

    bytes_written += chunk_written;
    }
    }

    void write_u32(uint32_t val) const {
    write_raw(&val, sizeof(val));
    }

    ~impl() {
    if (fp) {
    std::fclose(fp);
    }
    }
    #else
    impl(const char * fname, const char * mode) {
    fp = ggml_fopen(fname, mode);
    if (fp == NULL) {
    throw std::runtime_error(format("failed to open %s: %s", fname, strerror(errno)));
    }
    seek(0, SEEK_END);
    size = tell();
    seek(0, SEEK_SET);
    }

    size_t tell() const {
    // TODO: this ifdef is never true?
    #ifdef _WIN32
    __int64 ret = _ftelli64(fp);
    #else
    long ret = std::ftell(fp);
    #endif
    if (ret == -1) {
    throw std::runtime_error(format("ftell error: %s", strerror(errno)));
    }

    return (size_t) ret;
    }

    void seek(size_t offset, int whence) const {
    // TODO: this ifdef is never true?
    #ifdef _WIN32
    int ret = _fseeki64(fp, (__int64) offset, whence);
    #else
    int ret = std::fseek(fp, (long) offset, whence);
    #endif
    if (ret != 0) {
    throw std::runtime_error(format("seek error: %s", strerror(errno)));
    }
    }

    void read_raw(void * ptr, size_t len) const {
    if (len == 0) {
    return;
    }
    errno = 0;
    std::size_t ret = std::fread(ptr, len, 1, fp);
    if (ferror(fp)) {
    throw std::runtime_error(format("read error: %s", strerror(errno)));
    }
    if (ret != 1) {
    throw std::runtime_error("unexpectedly reached end of file");
    }
    }

    uint32_t read_u32() const {
    uint32_t ret;
    read_raw(&ret, sizeof(ret));
    return ret;
    }

    void write_raw(const void * ptr, size_t len) const {
    if (len == 0) {
    return;
    }
    errno = 0;
    size_t ret = std::fwrite(ptr, len, 1, fp);
    if (ret != 1) {
    throw std::runtime_error(format("write error: %s", strerror(errno)));
    }
    }

    void write_u32(uint32_t val) const {
    write_raw(&val, sizeof(val));
    }

    ~impl() {
    if (fp) {
    std::fclose(fp);
    }
    }
    #endif

    FILE * fp;
    size_t size;
    };

    llama_file::llama_file(const char * fname, const char * mode) : pimpl(std::make_unique<impl>(fname, mode)) {}
    llama_file::~llama_file() = default;

    size_t llama_file::tell() const { return pimpl->tell(); }
    size_t llama_file::size() const { return pimpl->size; }

    int llama_file::file_id() const {
    #ifdef _WIN32
    return _fileno(pimpl->fp);
    #else
    #if defined(fileno)
    return fileno(pimpl->fp);
    #else
    return ::fileno(pimpl->fp);
    #endif
    #endif
    }

    void llama_file::seek(size_t offset, int whence) const { pimpl->seek(offset, whence); }
    void llama_file::read_raw(void * ptr, size_t len) const { pimpl->read_raw(ptr, len); }

    uint32_t llama_file::read_u32() const { return pimpl->read_u32(); }

    void llama_file::write_raw(const void * ptr, size_t len) const { pimpl->write_raw(ptr, len); }
    void llama_file::write_u32(uint32_t val) const { pimpl->write_u32(val); }

    // llama_mmap

    struct llama_mmap::impl {
    #ifdef _POSIX_MAPPED_FILES
    std::vector<std::pair<size_t, size_t>> mapped_fragments;
    bool using_hugepages;

    impl(struct llama_file * file, size_t prefetch, bool numa) {
    size = file->size();
    int fd = file->file_id();
    int flags = MAP_SHARED;
    if (numa) { prefetch = 0; }
    #ifdef __linux__
    if (posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL)) {
    LLAMA_LOG_WARN("warning: posix_fadvise(.., POSIX_FADV_SEQUENTIAL) failed: %s\n",
    strerror(errno));
    }
    if (prefetch) { flags |= MAP_POPULATE; }
    #endif
    //addr = mmap(NULL, file->size(), PROT_READ, flags, fd, 0);
    //if (addr == MAP_FAILED) {
    // throw std::runtime_error(format("mmap failed: %s", strerror(errno)));
    //}

    // 尝试直接分配 1GB 大页匿名内存
    const size_t huge_page_size = 1 * 1024 * 1024 * 1024;
    size_t aligned_size = (file->size() + huge_page_size - 1) & ~(huge_page_size - 1);

    void* huge_addr = mmap(
    /*addr=*/nullptr,
    aligned_size,
    PROT_READ | PROT_WRITE,
    MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
    /*fd=*/-1,
    /*offset=*/0
    );

    if (huge_addr == MAP_FAILED) {
    // 如果大页分配失败,回退到普通 mmap
    LLAMA_LOG_WARN("huge page (%lx) allocation failed, using regular pages: %s\n",
    aligned_size, strerror(errno));
    addr = mmap(
    /*addr=*/nullptr,
    file->size(),
    PROT_READ,
    flags,
    fd,
    /*offset=*/0
    );
    if (addr == MAP_FAILED) {
    throw std::runtime_error(format("mmap failed: %s", strerror(errno)));
    }
    using_hugepages = false;
    } else {
    using_hugepages = true;
    // 成功分配大页后,直接将文件内容读取到大页区域,避免先普通 mmap 再 memcpy
    size_t total_read = 0;
    while (total_read < file->size()) {
    ssize_t ret = pread(
    fd,
    static_cast<char*>(huge_addr) + total_read,
    file->size() - total_read,
    total_read
    );
    if (ret < 0) {
    munmap(huge_addr, aligned_size);
    throw std::runtime_error(format("read to huge pages failed: %s", strerror(errno)));
    }
    if (ret == 0) {
    // 意外的 EOF
    break;
    }
    total_read += static_cast<size_t>(ret);
    }
    addr = huge_addr;
    }


    if (prefetch > 0) {
    if (posix_madvise(addr, std::min(file->size(), prefetch), POSIX_MADV_WILLNEED)) {
    LLAMA_LOG_WARN("warning: posix_madvise(.., POSIX_MADV_WILLNEED) failed: %s\n",
    strerror(errno));
    }
    }
    if (numa) {
    if (posix_madvise(addr, file->size(), POSIX_MADV_RANDOM)) {
    LLAMA_LOG_WARN("warning: posix_madvise(.., POSIX_MADV_RANDOM) failed: %s\n",
    strerror(errno));
    }
    }

    mapped_fragments.emplace_back(0,aligned_size);
    }

    static void align_range(size_t * first, size_t * last, size_t page_size) {
    size_t offset_in_page = *first & (page_size - 1);
    size_t offset_to_page = offset_in_page == 0 ? 0 : page_size - offset_in_page;
    *first += offset_to_page;

    *last = *last & ~(page_size - 1);

    if (*last <= *first) {
    *last = *first;
    }
    }

    void unmap_fragment(size_t first, size_t last) {
    if (using_hugepages) {
    // 使用大页时,禁用部分解除映射
    return;
    }
    int page_size = sysconf(_SC_PAGESIZE);
    align_range(&first, &last, page_size);
    size_t len = last - first;

    if (len == 0) {
    return;
    }

    GGML_ASSERT(first % page_size == 0);
    GGML_ASSERT(last % page_size == 0);
    GGML_ASSERT(last > first);

    void * next_page_start = (uint8_t *) addr + first;

    if (munmap(next_page_start, len)) {
    LLAMA_LOG_WARN("warning: munmap failed: %s\n", strerror(errno));
    }

    std::vector<std::pair<size_t, size_t>> new_mapped_fragments;
    for (const auto & frag : mapped_fragments) {
    if (frag.first < first && frag.second > last) {
    new_mapped_fragments.emplace_back(frag.first, first);
    new_mapped_fragments.emplace_back(last, frag.second);
    } else if (frag.first < first && frag.second > first) {
    new_mapped_fragments.emplace_back(frag.first, first);
    } else if (frag.first < last && frag.second > last) {
    new_mapped_fragments.emplace_back(last, frag.second);
    } else if (frag.first >= first && frag.second <= last) {
    } else {
    new_mapped_fragments.push_back(frag);
    }
    }
    mapped_fragments = std::move(new_mapped_fragments);
    }

    ~impl() {
    for (const auto & frag : mapped_fragments) {
    if (munmap((char *) addr + frag.first, frag.second - frag.first)) {
    LLAMA_LOG_WARN("warning: munmap failed: %s\n", strerror(errno));
    }
    }
    }
    #elif defined(_WIN32)
    impl(struct llama_file * file, size_t prefetch, bool numa) {
    GGML_UNUSED(numa);

    size = file->size();

    HANDLE hFile = (HANDLE) _get_osfhandle(file->file_id());

    HANDLE hMapping = CreateFileMappingA(hFile, NULL, PAGE_READONLY, 0, 0, NULL);

    if (hMapping == NULL) {
    DWORD error = GetLastError();
    throw std::runtime_error(format("CreateFileMappingA failed: %s", llama_format_win_err(error).c_str()));
    }

    addr = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
    DWORD error = GetLastError();
    CloseHandle(hMapping);

    if (addr == NULL) {
    throw std::runtime_error(format("MapViewOfFile failed: %s", llama_format_win_err(error).c_str()));
    }

    if (prefetch > 0) {
    #if _WIN32_WINNT >= 0x602
    BOOL (WINAPI *pPrefetchVirtualMemory) (HANDLE, ULONG_PTR, PWIN32_MEMORY_RANGE_ENTRY, ULONG);
    HMODULE hKernel32 = GetModuleHandleW(L"kernel32.dll");

    pPrefetchVirtualMemory = (decltype(pPrefetchVirtualMemory))(void *) GetProcAddress(hKernel32, "PrefetchVirtualMemory");

    if (pPrefetchVirtualMemory) {
    WIN32_MEMORY_RANGE_ENTRY range;
    range.VirtualAddress = addr;
    range.NumberOfBytes = (SIZE_T) std::min(size, prefetch);
    if (!pPrefetchVirtualMemory(GetCurrentProcess(), 1, &range, 0)) {
    LLAMA_LOG_WARN("warning: PrefetchVirtualMemory failed: %s\n",
    llama_format_win_err(GetLastError()).c_str());
    }
    }
    #else
    throw std::runtime_error("PrefetchVirtualMemory unavailable");
    #endif
    }
    }

    void unmap_fragment(size_t first, size_t last) {
    GGML_UNUSED(first);
    GGML_UNUSED(last);
    }

    ~impl() {
    if (!UnmapViewOfFile(addr)) {
    LLAMA_LOG_WARN("warning: UnmapViewOfFile failed: %s\n",
    llama_format_win_err(GetLastError()).c_str());
    }
    }
    #else
    impl(struct llama_file * file, size_t prefetch, bool numa) {
    GGML_UNUSED(file);
    GGML_UNUSED(prefetch);
    GGML_UNUSED(numa);

    throw std::runtime_error("mmap not supported");
    }

    void unmap_fragment(size_t first, size_t last) {
    GGML_UNUSED(first);
    GGML_UNUSED(last);

    throw std::runtime_error("mmap not supported");
    }
    #endif

    void * addr;
    size_t size;
    };

    llama_mmap::llama_mmap(struct llama_file * file, size_t prefetch, bool numa) : pimpl(std::make_unique<impl>(file, prefetch, numa)) {}
    llama_mmap::~llama_mmap() = default;

    size_t llama_mmap::size() const { return pimpl->size; }
    void * llama_mmap::addr() const { return pimpl->addr; }

    void llama_mmap::unmap_fragment(size_t first, size_t last) { pimpl->unmap_fragment(first, last); }

    #if defined(_POSIX_MEMLOCK_RANGE) || defined(_WIN32)
    const bool llama_mmap::SUPPORTED = true;
    #else
    const bool llama_mmap::SUPPORTED = false;
    #endif

    // llama_mlock

    struct llama_mlock::impl {
    #ifdef _POSIX_MEMLOCK_RANGE
    static size_t lock_granularity() {
    return (size_t) sysconf(_SC_PAGESIZE);
    }

    bool raw_lock(const void * addr, size_t size) const {
    if (!mlock(addr, size)) {
    return true;
    }

    #ifdef __APPLE__
    #define MLOCK_SUGGESTION \
    "Try increasing the sysctl values 'vm.user_wire_limit' and 'vm.global_user_wire_limit' and/or " \
    "decreasing 'vm.global_no_user_wire_amount'. Also try increasing RLIMIT_MEMLOCK (ulimit -l).\n"
    #else
    #define MLOCK_SUGGESTION \
    "Try increasing RLIMIT_MEMLOCK ('ulimit -l' as root).\n"
    #endif

    char* errmsg = std::strerror(errno);
    bool suggest = (errno == ENOMEM);

    struct rlimit lock_limit;
    if (suggest && getrlimit(RLIMIT_MEMLOCK, &lock_limit)) {
    suggest = false;
    }
    if (suggest && (lock_limit.rlim_max > lock_limit.rlim_cur + size)) {
    suggest = false;
    }

    LLAMA_LOG_WARN("warning: failed to mlock %zu-byte buffer (after previously locking %zu bytes): %s\n%s",
    size, this->size, errmsg, suggest ? MLOCK_SUGGESTION : "");
    return false;
    }

    static void raw_unlock(void * addr, size_t size) {
    if (munlock(addr, size)) {
    LLAMA_LOG_WARN("warning: failed to munlock buffer: %s\n", std::strerror(errno));
    }
    }
    #elif defined(_WIN32)
    static size_t lock_granularity() {
    SYSTEM_INFO si;
    GetSystemInfo(&si);
    return (size_t) si.dwPageSize;
    }

    bool raw_lock(void * ptr, size_t len) const {
    for (int tries = 1; ; tries++) {
    if (VirtualLock(ptr, len)) {
    return true;
    }
    if (tries == 2) {
    LLAMA_LOG_WARN("warning: failed to VirtualLock %zu-byte buffer (after previously locking %zu bytes): %s\n",
    len, size, llama_format_win_err(GetLastError()).c_str());
    return false;
    }

    SIZE_T min_ws_size, max_ws_size;
    if (!GetProcessWorkingSetSize(GetCurrentProcess(), &min_ws_size, &max_ws_size)) {
    LLAMA_LOG_WARN("warning: GetProcessWorkingSetSize failed: %s\n",
    llama_format_win_err(GetLastError()).c_str());
    return false;
    }
    size_t increment = len + 1048576;
    min_ws_size += increment;
    max_ws_size += increment;
    if (!SetProcessWorkingSetSize(GetCurrentProcess(), min_ws_size, max_ws_size)) {
    LLAMA_LOG_WARN("warning: SetProcessWorkingSetSize failed: %s\n",
    llama_format_win_err(GetLastError()).c_str());
    return false;
    }
    }
    }

    static void raw_unlock(void * ptr, size_t len) {
    if (!VirtualUnlock(ptr, len)) {
    LLAMA_LOG_WARN("warning: failed to VirtualUnlock buffer: %s\n",
    llama_format_win_err(GetLastError()).c_str());
    }
    }
    #else
    static size_t lock_granularity() {
    return (size_t) 65536;
    }

    bool raw_lock(const void * addr, size_t len) const {
    LLAMA_LOG_WARN("warning: mlock not supported on this system\n");
    return false;
    }

    static void raw_unlock(const void * addr, size_t len) {}
    #endif

    impl() : addr(NULL), size(0), failed_already(false) {}

    void init(void * ptr) {
    GGML_ASSERT(addr == NULL && size == 0);
    addr = ptr;
    }

    void grow_to(size_t target_size) {
    GGML_ASSERT(addr);
    if (failed_already) {
    return;
    }
    size_t granularity = lock_granularity();
    target_size = (target_size + granularity - 1) & ~(granularity - 1);
    if (target_size > size) {
    if (raw_lock((uint8_t *) addr + size, target_size - size)) {
    size = target_size;
    } else {
    failed_already = true;
    }
    }
    }

    void * addr;
    size_t size;

    bool failed_already;
    };

    llama_mlock::llama_mlock() : pimpl(std::make_unique<impl>()) {}
    llama_mlock::~llama_mlock() = default;

    void llama_mlock::init(void * ptr) { pimpl->init(ptr); }
    void llama_mlock::grow_to(size_t target_size) { pimpl->grow_to(target_size); }

    #if defined(_POSIX_MEMLOCK_RANGE) || defined(_WIN32)
    const bool llama_mlock::SUPPORTED = true;
    #else
    const bool llama_mlock::SUPPORTED = false;
    #endif

    size_t llama_path_max() {
    return PATH_MAX;
    }
  • 本地编译:

    1
    2
    cmake -B build
    cmake --build build --config Release -j$(nproc)
  • 修改 grub 文件:

    1
    sudo vim /etc/default/grub

    增加一行修改内核参数:

    1
    GRUB_CMDLINE_LINUX_DEFAULT="quiet splash default_hugepagesz=1G hugepagesz=1G hugepages=671"

    上述hugepages的数值 q8 量化建议取671,q4 量化建议取386
    使修改生效:

    1
    sudo update-grub
  • 重启电脑后系统开启了 1G 大页,预留了足够的内存空间加载 q8 精度的权重文件,并修改了 llama.cpp 给模型权重分配内存空间的代码,强制其使用操作系统预留的 1G 大页,减少了访存时的 TLB miss 率,从而优化了内存带宽,显著提升了生成速度,性能优化详细原理可阅读后续的《优化分析篇》。

  • 配置模型启动参数
    下载DeepSeek-R1-Zero-Q8_K_M 权重到当前目录
    进入/build/bin 目录启动服务:

    1
    ./llama-server -m ./DeepSeek-R1-Zero-Q8_K_M/DeepSeek-R1-Zero-BF16-256x20B-Q8_0-00001-of-00016.gguf  --host 0.0.0.0 --port 8008 --temp 0.6 --cache-type-k q8_0 -t 16 -tb 32 --ctx-size 4096 -np 1 --jinja --chat-template-file ../../models/templates/llama-cpp-deepseek-r1.jinja --reasoning-format deepseek

其中 –jinja –chat-template-file ./llama.cpp/models/templates/llama-cpp-deepseek-r1.jinja –reasoning-format deepseek 是强制模型进行深度思考,若不需要强制思考则不需要这些参数。 -t 16 和 -tb 32 分别指定生成和预填充时的核心数量可以避免因抢夺 ccd 带宽时的系统开销同时合理充分利用超线程带来的的额外算力,一般情况下生成使用超线程是负优化,预填充使用超线程可以提高速度,具体分析请等待后续文章《优化分析篇》。–ctx-size 设置的是支持的上下文长度,-np 设置的是支持的并发数。带入下面的公式可以计算出支持的最大的上下文数量:

q4 量化上下文和并发计算公式:
768GB = 376GB + 3.425GB * ctx_size(上下文长度 单位K) * np(并发数) + sys_mem
q8 量化上下文和并发计算公式:
768GB = 664GB + 3.425GB * ctx_size(上下文长度 单位K) * np(并发数) + sys_mem

例如当单并发即 np=1 ,系统内存占用 20GB 即 sys_mem=20GB 时,代入上述公式,计算得出 768GB 总内存方案理论上支持q4模型上下文108K,支持 q8 模型上下文24K

0x03 性能测试与量化对比篇

性能测试

峰值生成速度测试

  • q8测试命令:

    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q8_0/DeepSeek-R1.Q8_0-00001-of-00015.gguf -npp 16 -ntg 128 -npl 1,1,1,1,1 --ctx-size 144 --cache-type-k q8_0 -t 16 -tb 32
  • q8测试结果:
    q8峰值生成速度为:7.17 tokens/s

  • q4测试命令:

    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q4_K_M/DeepSeek-R1-Q4_K_M-00001-of-00009.gguf -npp 16 -ntg 128 -npl 1,1,1,1,1 --ctx-size 144 --cache-type-k q8_0 -t 16 -tb 32
  • q4测试结果:
    q4峰值生成速度为:10.24 tokens/s

长文本生成速度测试

  • q8 测试命令:

    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q8_0/DeepSeek-R1.Q8_0-00001-of-00015.gguf -npp 16 -ntg 256,512,1024,2048,3072,4096,5120,6144,8192 -npl 1 --ctx-size 8208 --cache-type-k q8_0 -t 16 -tb 32
  • q8 测试结果(输出长度->输出速度):

256->6.96 512->6.86 1024->6.66 2048->6.25 3072->5.90 4096->5.59 5120->5.32 6144->5.06 8192->4.65

  • q4 测试命令:
    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q4_K_M/DeepSeek-R1-Q4_K_M-00001-of-00009.gguf -npp 16 -ntg 256,512,1024,2048,3072,4096,5120,6144,8192 -npl 1 --ctx-size 8208 --cache-type-k q8_0 -t 16 -tb 32
  • q4 测试结果(输出长度->输出速度):

256->9.90 512->9.69 1024->9.30 2048->8.47 3072->7.86 4096->7.31 5120->6.86 6144->6.47 8192->5.81

并发生成速度测试

  • q8 测试命令:
    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q8_0/DeepSeek-R1.Q8_0-00001-of-00015.gguf -npp 16 -ntg 128 -npl 2,4,6,8,10,12,14,16 --ctx-size 2304 --cache-type-k q8_0 -t 16 -tb 32
  • q8 测试结果(并发数->总输出速度):

2->9.83 4->13.59 6->14.01 8->15.85 10->15.52 12->16.45 14->16.01 16->16.66

  • q4 测试命令:
    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q4_K_M/DeepSeek-R1-Q4_K_M-00001-of-00009.gguf -npp 16 -ntg 128 -npl 2,4,6,8,10,12,14,16 --ctx-size 2304 --cache-type-k q8_0 -t 16 -tb 32
  • q4 测试结果(并发数->总输出速度):
  • 2->11.44 4->13.74 6->14.61 8->15.11 10->15.25 12->15.49 14->15.42 16->15.64

首字延迟测试

  • q8 测试命令:
    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q8_0/DeepSeek-R1.Q8_0-00001-of-00015.gguf -npp 16,32,64,128,256,512,1024,2048,3072,4096,5120,6144,8192 -ntg 128 -npl 1 --ctx-size 8320 --cache-type-k q8_0 -t 16 -tb 32
  • q8 测试结果(输入长度->首字延迟):

16->1.158 32->1.960 64->3.504 128->6.518 256->12.668 512->25.542 1024->52.308 2048->107.651 3072->165.463 4096->226.759 5120->291.854 6144->360.790 8192->509.968

  • q4 测试命令:
    1
    ./llama-batched-bench --model /data/share/DeepSeek-R1-Q4_K_M/DeepSeek-R1-Q4_K_M-00001-of-00009.gguf -npp 16,32,64,128,256,512,1024,2048,3072,4096,5120,6144,8192 -ntg 128 -npl 1 --ctx-size 8320 --cache-type-k q8_0 -t 16 -tb 32
  • q4 测试结果(输入长度->首字延迟):

16->1.195 32->2.071 64->3.705 128->7.056 256->13.672 512->27.586 1024->56.148 2048->115.911 3072->177.734 4096->242.441 5120->311.779 6144->384.282 8192->540.841

性能测试中的有趣发现

  • 单并发的decoding 性能瓶颈在内存带宽,是严格的 memory bounding
  • prefill 和4并发以后 decoding 性能瓶颈在算力,另我们还在单路 192 物理核的CPU 上做了实验,发现核心数量越多 prefill 和并发decoding 速度提升显著,但是提高 CPU 主频带来的提升很小
  • q4 量化精度由于在计算时需要额外的解码步骤(unpacking)而SIMD指令集(如AVX-512)对 8-bit 整数的点积运算有原生支持,导致 q4 在 prefill 和多并发 decoding 这样内存带宽不是瓶颈的场景下实际性能甚至略低于q8

性能测试小技巧分享

为了使得测试结果稳定可复现,建议在进行严肃的性能测试前,先清空缓存(cache flush)并进行程序预热(warm-up),以使得软硬件处于稳定状态。具体步骤如下:

  • 关闭无用的非系统进程和服务
  • 清空缓存:
    1
    2
    sync
    sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches’
  • 使用任意测试命令对系统进行 warm-up
    1
    ./llama-bench --model /data/share/DeepSeek-R1-Q8_0/DeepSeek-R1.Q8_0-00001-of-00015.gguf --cache-type-k q8_0
  • 再次清空缓存
  • 若测试结果仍然不稳定,排查是否有其他进程在使用内存带宽或者内存颗粒/内存供电mos过热

量化对比实验

目前很少有评估q8量化与q4量化能力对比的评测,我们从困惑度和答题两个维度对 q4 相对 q8或官方 fp8的劣化程度进行了测评

Wikipedia 困惑度实验:

使用wiki.test.raw进行困惑度测试

  • q8 测试命令:

    1
    ./llama-perplexity -m /data/share/DeepSeek-R1-Q8_0/DeepSeek-R1.Q8_0-00001-of-00015.gguf  -bf wiki.test.raw
  • q8 测试结果:
    困惑度平均值:2.5804

  • q4 测试命令:

    1
    ./llama-perplexity -m /data/share/DeepSeek-R1-Q4_K_M/DeepSeek-R1-Q4_K_M-00001-of-00009.gguf -bf wiki.test.raw
  • q4 测试结果:
    困惑度平均值:2.6246

求解问题的推理性能

我们一开始使用去年高考数学客观题进行评测,很快发现由于现在推理模型普遍做题能力很强,用r1的q8/q4去做这些题就像UFC格斗世界冠军和第100名打咱们都是三秒后倒地。所以最后我们选择了从最新的 SuperGPQA中抽取 5 道hard 难度的题,然后加测了一道几何题。分别测试q8和 q4 量化的模型,考察维度为COT 长度和回答对错。另作为参考引入了o1-pro 做对比参照。测试结果如下表所示:

结果分析:

  • r1 在其技术文档中没有提及使用了量化感知训练(QAT),所以对其进行量化后而导致的能力损失不可以忽视。另有研究分析过r1的网络层欠饱和程度(underfitting)只有10%,根据以往经验训练越饱和的模型量化损失越大。从测试结果来看也确实如此, q4 的cot 平均长度比q8长了 45%,困惑度高了 1.7%
  • 由于 q4 精度要多推理 45% 的无效 token,所以导致在这台主机上q8 完成任务的时间甚至比 q4 还要短
  • 仔细观察 q4 比 q8 额外多出来的token,存在大量反思回退操作,一般来说如果反思回退次数过多,导致 cot 长度超过一万个 token 时,准确率会出现断崖下降
  • 官网的 COT 长度和 q8 接近但略长一点点,猜测这可能是因为官网的模型版本比开源时更新或者是部署方式不同导致的

0x04 总结与展望

本文分享了我们对 DeepSeek 大模型(671B 参数)纯 CPU 私有化部署的实践与理解,旨在帮助发烧友和相关从业人员避免配置上的弯路。我们围绕硬件选型、量化策略、散热优化、BIOS 设置及系统调优等多个方面展开了介绍,为社区提供了一种性价比高、性能稳定的私有化部署新选择。在下一篇文章《性能优化分析篇》中,我们将继续深入探讨在优化过程中遇到的实际问题和经验,包括内存带宽瓶颈分析、CPU 核心利用率调优以及其他系统级优化策略。同时,也会对纯 CPU 方案未来的性能提升空间进行展望,敬请期待!