DeepSeek-671B纯CPU部署经验分享(一)
私有化部署大模型能够有效保护数据隐私、便于开展大模型安全研究和知识蒸馏。目前主流部署方式包括纯 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 
 2git 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 // TODO: consider moving to llama-impl.h if needed in more places
 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;
 }
 // llama_file
 struct llama_file::impl {
 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);
 }
 }
 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?
 __int64 ret = _ftelli64(fp);
 long ret = std::ftell(fp);
 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?
 int ret = _fseeki64(fp, (__int64) offset, whence);
 int ret = std::fseek(fp, (long) offset, whence);
 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);
 }
 }
 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 {
 return _fileno(pimpl->fp);
 return fileno(pimpl->fp);
 return ::fileno(pimpl->fp);
 }
 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 {
 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; }
 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; }
 //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));
 }
 }
 }
 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) {
 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());
 }
 }
 throw std::runtime_error("PrefetchVirtualMemory unavailable");
 }
 }
 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());
 }
 }
 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");
 }
 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); }
 const bool llama_mmap::SUPPORTED = true;
 const bool llama_mmap::SUPPORTED = false;
 // llama_mlock
 struct llama_mlock::impl {
 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;
 }
 "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"
 "Try increasing RLIMIT_MEMLOCK ('ulimit -l' as root).\n"
 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));
 }
 }
 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());
 }
 }
 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) {}
 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); }
 const bool llama_mlock::SUPPORTED = true;
 const bool llama_mlock::SUPPORTED = false;
 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 
 2sync 
 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 方案未来的性能提升空间进行展望,敬请期待!
