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
2cmake -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/sq4测试命令:
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.5804q4 测试命令:
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 方案未来的性能提升空间进行展望,敬请期待!