私有化部署大模型能够有效保护数据隐私、便于开展大模型安全研究和知识蒸馏。目前主流部署方式包括纯 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 源码:
git clone https://github.com/ggml-org/llama.cpp.git
git checkout 20a9b8f5e1380243ed03aeb50ae1bf94b8d68501
- 用下面的代码替换掉 osrc 目录下的 llama-mmap.cpp 文件里的内容使用 Hugepage 大页代码如下
#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;
}
- 本地编译:
cmake -B build
cmake --build build --config Release -j$(nproc)
- 修改 grub 文件:
sudo vim /etc/default/grub
增加一行修改内核参数:
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash default_hugepagesz=1G hugepagesz=1G hugepages=671"
上述 hugepages 的数值 q8 量化建议取 671,q4 量化建议取 386
- 使修改生效:
sudo update-grub
- 重启电脑后系统开启了 1G 大页,预留了足够的内存空间加载 q8 精度的权重文件,并修改了 llama.cpp 给模型权重分配内存空间的代码,强制其使用操作系统预留的 1G 大页,减少了访存时的 TLB miss 率,从而优化了内存带宽,显著提升了生成速度,性能优化详细原理可阅读后续的《优化分析篇》。
配置模型启动参数
下载 DeepSeek-R1-Zero-Q8_K_M 权重到当前目录
进入/build/bin 目录启动服务:
./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 测试命令:
./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 测试命令:
./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 测试命令:
./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 测试命令:
./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 测试命令:
./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 测试命令:
./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 测试命令:
./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 测试命令:
./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),以使得软硬件处于稳定状态。具体步骤如下:
- 关闭无用的非系统进程和服务
- 清空缓存:
sync
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches’
- 使用任意测试命令对系统进行 warm-up
./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 测试命令:
./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 测试命令:
./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 方案未来的性能提升空间进行展望,敬请期待!
原文链接:https://xlab.tencent.com/cn/2...(%E4%B8%80)
扩展阅读:DeepSeek-R1 671B 最小化部署实验:CPU+192GB 内存
解锁 DeepSeek-R1 671B FP8 推理性能:5,921 token/s @ AMD MI300X
DeepSeek 时代:关于 AI 服务器的技术思考(PCIe 篇)
END
作者:腾讯玄武实验室
原文:企业存储技术
推荐阅读
欢迎关注企业存储技术极术专栏,欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。