Towards Data Science

3 Agents. 3 LLMs. 1 Aging GPU: Engineering Parallel Inference on Bare Metal

8.5内容质量

TL;DR · AI 摘要

通过5G式接入控制与异步分层流技术,可在老旧GPU上并行运行多个LLM,解决内存不足问题。

核心要点

  • 使用5G式接入控制(lmxd)可有效管理GPU资源分配。
  • 异步双缓冲分层流技术可提升多LLM并行推理效率。
  • C++守护进程lmxd可在8GB显存的GTX 1080上运行三个LLM。

结构提纲

按章节快速跳转。

  1. 介绍在老旧GPU上运行多个LLM所面临的挑战。

  2. 描述在单个GPU上运行多个LLM时出现的内存不足问题。

  3. 介绍通过5G式接入控制和异步分层流技术解决内存不足问题的方法。

  4. lmxd守护进程

    介绍lmxd守护进程如何实现多LLM并行推理。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 多LLM并行推理优化
    • 问题
      • GPU显存不足
      • 多LLM无法并行运行
    • 解决方案
      • 5G式接入控制(lmxd)
      • 异步双缓冲分层流技术
      • C++守护进程lmxd

金句 / Highlights

值得收藏与分享的关键句。

#LLM#GPU优化#C++#并行推理
打开原文

3 个代理。3 个大语言模型。1 个老化的 GPU:在裸金属上构建并行推理 | Towards Data Science

代理式人工智能

3 个代理。3 个大语言模型。1 个老化的 GPU:在裸金属上构建并行推理

一个小型的 C++ 守护进程如何使用 5G 风格的接入控制和异步层流水线技术,在一个 8 年前的显卡上运行三个大语言模型。

Anubhab Banerjee

2026 年 6 月 25 日

21 分钟阅读

分享

VRAM Conductor 架构:将 5G 风格的连接接入控制(lmxd)与异步双缓冲层流技术相结合,安全地在单个 8GB GTX 1080 显卡上复用多个大语言模型。图像由 Claude 4.7 Opus 生成。

你有三个 AI 代理,分别使用三个不同的大语言模型。你有一个古老的 GPU,而且你太穷了,无法升级。你需要并行运行这些代理,但在这个老化的 GPU 上,只有一个能存活下来,另外两个会崩溃。这里有一个小型的 C++ 守护进程,解决了这个问题,并讲述了它们如何一起存活下来的真实故事。

你实际面临的问题

让我描述一个你很容易产生共鸣的情况。

你有几个 AI 代理:代理 A 生成原始代码,代理 B 在代码编写过程中主动检查安全漏洞,代理 C 同时起草文档。为了实现无缝、实时的开发者体验,避免出现严重的延迟峰值,这三个代理必须同时驻留在内存中。它们各自与不同的小型指令大语言模型配合效果最佳——这里是一个 SmolLM,那里是一个 Qwen,另一个地方是一个小型 Llama。你将它们指向你的机器,这台机器上有一个非常老旧的 GPU。你这个季度、今年,甚至可能这辈子都无法升级它(是的,你真的那么穷!)。它是一块 NVIDIA GTX 1080 显卡,只有 8GB 的显存,多年来你一直被悄悄告知,它对于“小型”模型来说应该还是足够的。

于是你做了显而易见的事情。你打开三个终端,通过三个 llama-completion 进程并行启动这些代理。然后你等待看到以下情况:

你:“尽管 GPU 很老,三个小型模型应该可以正常运行。” llama-completion(代理 1,Llama 3.2 1B):“正在加载后端。为 n_ctx=172032,n_batch=8192,-ngl 99 预留 KV 缓存。” 显存使用量跃升至 6,536 MiB,总显存为 8,192 MiB。你:“不错。现在是代理 2 —— Qwen2 0.5B。” llama-completion(代理 2):“在设备 0 上分配 1,536 MiB…” cudaMalloc:“❌ 内存不足。” llama-completion(代理 2):“qwen 进程结束。” 🫡 你:“好吧。那运行 SmolLM2 360M,这个模型很小。” llama-completion(代理 3):“在设备 0 上分配 5,120 MiB…” cudaMalloc:“❌ 内存不足。” llama-completion(代理 3):“smol 进程结束。”

你的 nvidia-smi:仍然只显示一个进程使用了 6,512 MiB。你的三个代理演示:实际上是一个代理演示,另外两个有崩溃日志。

如果你看到的情况类似,那么你并没有做错什么。你正在做每一个“单 GPU 上的多代理”教程告诉你的事情。问题是,教程对硅芯片过于乐观。不过不用担心,我有一个解决方案,这就是本文的重点。

本文其余部分包括两个内容:一分钟解释为什么第二个和第三个进程会崩溃,以及一个小型的 C++ 守护进程 lmxd,它可以让这三个进程在同一个显卡上运行,而无需经历内存不足的随机性。你可能想作为背景阅读的唯一先前文章是 Warpgroup-backend,即使这也不是必须的。

llama.cpp 的 llama-completion(及其相关组件)在创建 llama_context 时,会提前为整个配置的上下文窗口保留 KV 缓存(解码过程中注意力层使用的每个 token 的内存)。是的,这是在一开始就进行的,而不是在解码过程中逐步进行的,这样可以确保解码过程顺利进行,没有任何卡顿。使用 -c 172032 和 -ngl 99 时,第一个进程在解码任何单个 token 之前,就会占用你显卡上 6,536 MiB / 8,192 MiB 的内存,而其中几乎没有是模型权重。这完全是 KV 预留造成的。

此时,当第二个进程尝试构建自己的上下文时,日志中会显示如下内容:

code
0.00.592.688 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 1536.00 MiB
              on device 0: cudaMalloc failed: out of memory
0.00.593.132 E llama_init_from_model: failed to initialize the context:
              failed to allocate buffer for kv cache

这不是一个 bug。这是 llama.cpp 为单个进程所做的安全处理:提前预留 KV,确保解码过程不会中途停滞。然而,当三个独立的进程在同一个 8 GB 显卡上都这么做时,这却是一个非常危险的做法,因为显卡没有队列,也没有共享的会计机制。一旦显卡使用量超过约 80%,cudaMalloc 就变成了一个真正的“抛硬币”游戏。

解决方法并不是“一个更好的算法”。它是最简单的:账本管理。必须有人查看显卡,判断下一个代理是否真的能适应,然后在下一个进程尝试分配之前拒绝请求。我知道,对吧!让我们来构建它。如果你需要帮助,请在本文的最后找到 GitHub 仓库。

解决方案:一个执行账本管理的小型 C++ 守护进程

我们需要一个账本管理员,所以让我们来构建这个账本管理员。lmxd 是一个长期运行的进程(约 1,500 行 C++17 代码),它代表代理拥有 GPU。代理不会再启动自己的 llama-completion 二进制文件,它们只是通过一个小型的 Unix 套接字文本协议(HELP、STATUS、LIST、REGISTER、UNREGISTER)与守护进程通信,而守护进程决定新代理是否可以加入,并且只有在确认可以加入后才会加载模型。

整个策略只包含一个数字:显卡总 VRAM 的 90%,以及一个规则:只有在当前已使用量 + 新代理预估使用量 ≤ 90% 的上限时,才允许新代理加入。以下的所有内容只是对这一规则的诚实实现。

执行上限的账本足够小,可以在一次阅读中完成。从 src/vram_ledger.cpp 中:

code
bool VramLedger::try_reserve(uint64_t model_table_bytes) {
  // 单个关键部分覆盖比较和增加操作,以防止并行 REGISTER 超出限制。
  std::lock_guard<std::mutex> lock(mu_);
  if (!initialized_) {
    return false;
  }
  uint64_t projected = 0;
  if (!add_u64(allocated_bytes_, model_table_bytes, &projected)) {
    return false;
  }
  if (projected > max_vram_bytes_) {
    return false;
  }
  allocated_bytes_ = projected;
  return true;
}

只有几行真正执行策略的代码。检查总和是否不会静默溢出,同时确保预估总和在上限之下,然后才进行提交。互斥锁很重要:如果两个代理同时调用 REGISTER,不能让它们都通过检查并基于过时状态超出限制。我知道,当我代表所有人说我们已经调试过这个竞争条件时,没有人会喜欢这个过程。

code
uint64_t v_new = 0;
    if (!table_.lookup(model_key, &v_new)) {
      return std::string("ERR unknown model_key for VRAM table lookup\n");
    }
    if (!ledger_.try_reserve(v_new)) {
      const VramLedger::Snapshot st = ledger_.snapshot();
      std::ostringstream os;
      os << "ERR VRAM_LEDGER_DENY code=CAP ledger_max_bytes=" << st.max_bytes
         << " ledger_allocated_bytes=" << st.allocated_bytes << " requested_table_bytes=" << v_new
         << "\n";
      return os.str();
    }
    std::string lerr;
    if (!llama_.acquire_model(model_key, &lerr)) {
      ledger_.release(v_new);
      return std::string("ERR llama acquire failed: ") + lerr + "\n";
    }
    // 记录代理的每个上下文插槽,以便后续的 DECODE 调用可以通过 LlamaContextManager 驱动 KV-swap 操作。
    // 插槽的创建仅记录,尚未构建任何上下文。
    std::string cerr;
    if (!ctx_mgr_.create_slot(agent_id, model_key, &cerr)) {
      // 回滚:释放模型引用计数和账本字节数,确保注册失败时不会留下任何痕迹。
      llama_.release_model(model_key);
      ledger_.release(v_new);
      return std::string("ERR ctx_mgr create_slot failed: ") + cerr + "\n";
    }
    agent_to_model_[agent_id] = model_key;
    std::ostringstream os;
    os << "OK registered agent=" << agent_id << " model=" << model_key << "\n";

在这一点上,如果我们花几分钟仔细看一下这些代码行,会非常有帮助。它们遵循一个简单的操作顺序:先查找字节估计值,然后在账本中进行保留,最后加载模型。如果账本拒绝了请求,守护进程将返回一个结构化的 ERR VRAM_LEDGER_DENY code=CAP 错误信息,并且不会接触 GPU。如果账本接受了请求,但模型加载由于其他原因(如磁盘 I/O、文件损坏等)失败,那么在同一条路径上会释放保留的资源。最终,代理要么成功注册并占用其字节配额,要么什么都不会发生。

这可能是几乎所有“简单”版本中容易出错的地方。如果你先加载模型,再检查预算,那么你已经为一个即将被拒绝的模型支付了磁盘 I/O 的代价,并且你距离成功加载一个无法在任何地方计费的模型只差一次竞态条件。在构建之前,先预订资源。始终如此。

模型加载器本身还做了一件无聊但重要的事情:一个进程、一个 llama_backend_init、一个对 GGUF 路径到已加载模型的引用计数映射。从 src/llama_single_service.cpp:

code
bool LlamaSingleService::acquire_model(const std::string& path, std::string* err_out) {
  // 保护每个 llama 入口点,确保多代理注册不会与运行时发生竞态。
  std::lock_guard<std::mutex> lock(mu_);

  if (!backend_inited_) {
    // 仅初始化一次 CPU/GPU 后端;后续调用只是便宜的引用计数增加。
    llama_backend_init();
    backend_inited_ = true;
  }

  const auto it = models_.find(path);
  if (it != models_.end()) {
    // 重用已映射的 GGUF,并为新代理租户增加引用计数。
    it->second.refcount += 1;
    return true;
  }

  // 新路径:加载默认参数,然后通过 llama.cpp 解析器从磁盘映射权重。
  llama_model_params params = llama_model_default_params();
  llama_model* model = llama_model_load_from_file(path.c_str(), params);
  if (model == nullptr) {
    if (err_out != nullptr) {
      *err_out = "llama_model_load_from_file failed for path: " + path;
    }
    return false;
  }
cpp
ModelSlot slot{};
slot.model = model;
slot.refcount = 1;
models_.emplace(path, std::move(slot));
return true;
}

守护进程会精确地调用一次 llama_backend_init。朴素的“三个终端”方法会在同一张显卡上创建三个独立的 CUDA 主上下文,每个上下文在任何张量被访问之前都会消耗数百兆字节的静默驱动程序开销。守护进程拒绝这样做——每个 GPU 只使用一个后端。此外,如果两个代理请求完全相同的 GGUF,它只会将权重映射到 VRAM 一次,并简单地增加一个引用计数器。

这就是整个系统:一个数字(90%),一个诚实的账本,一个严格的操作顺序,以及一个共享的后端。这不是新的计算机科学。这是记账,用 C++ 编写并通过 Unix 套接字暴露出来,这样凌晨 2 点疲惫的工程师可以运行 nc -U /tmp/lmxd.sock 并直接与它交谈。

收据(即一个丑陋的表格和五张截图)

整个演示可以打包成一个 shell 脚本(尽管目前仓库中没有提供,因为我的重点是解决方案部分,而不是演示部分):该脚本依次运行朴素堆栈和守护进程堆栈,并生成 PNG 和转录对。相同的硬件,相同的三个 GGUF(SmolLM2-360M-Instruct-Q4_K_M、Qwen2-0.5B-Instruct-Q4_K_M、Llama-3.2-1B-Instruct-Q4_K_M——总共约 1.4 GiB 的磁盘空间,全部来自 bartowski 的 Hugging Face 账户),相同的 -ngl 99 -c 172032。显卡是 NVIDIA GTX 1080(8 GB,Pascal),驱动程序版本 535.309.01,llama.cpp 固定在标签 b9724 上。

#### 轨道 A — 三个终端,三个 llama-completion 二进制文件

在任何操作之前,基准状态:显卡上使用了 22 MiB。

首先启动 Llama 3.2 1B。一个进程,立即使用了 6,536 MiB:

尝试添加 Qwen2 0.5B。转录结束时显示 qwen 进程结束,并出现一个 cudaMalloc failed: out of memory while allocating a 1,536 MiB KV buffer 错误:

尝试 SmolLM2 360M — 三个模型中最小的一个。结果相同:

最终得分:一个进程驻留,两个崩溃日志。所谓的“三个代理演示”实际上是只有一个代理的演示,带有两个错误跟踪。

#### 轨道 B — lmxd 接受所有三个模型

相同的显卡。相同的三个模型。使用一个小文本表格,将每个 GGUF 映射到其磁盘上的大小,设置一个 90% 的 --admission-percent 预算,并通过 nc -U 发送三个连续的 REGISTER 命令。最终的 STATUS + LIST 交换是本文的全部重点:

三个不同的小型指令模型,三个不同的代理 ID,在相同的硬件上,使用了 1.58 GB 的预算,而轨道 A 仅有一个幸存者,上限为 7.73 GB。

重点不是“守护进程更快”。重点是守护进程成功地放置了三个,而朴素堆栈只成功放置了一个。当它最终拒绝请求时,它会返回一行单一的线 —— ERR VRAM_LEDGER_DENY code=CAP ledger_max_bytes=... ledger_allocated_bytes=... requested_table_bytes=... —— 包含操作员调试所需的确切三个数字。自我解释的失败,是不是最美丽的事情?

承认代理的存在只是问题的一半。如果这些代理无法并行运行,那么整篇文章的内容就毫无意义了。另一半是决定哪个代理当前能够获得实时的 llama_context,因为任何给定的毫秒内,只有一个代理在进行张量的乘法运算。守护进程也包含了这一部分:每个代理的上下文生命周期在 lmx::LlamaContextManager 中管理,真正的 llama.cpp 解码在 lmx::AgentRuntime 中执行,每次代理切换时,通过 lmx::KvSwapHelper 将 KV 缓存从 GPU 释放到主机的 RAM 中,这一切都通过一个额外的 IPC 操作 —— DECODE 来实现。

同一个套接字。两个已注册的代理(smol、qwen)。我们发出三次连续的 DECODE 调用,以在它们之间切换:一次冷启动,一次将当前活动代理的 KV 缓存交换到主机内存中,最后一次将它交换回一个全新的上下文中。在每一步,线路上的响应都会明确报告守护进程必须移动的内容:

三次调用,三次不同的 KV 交换状态,总共在相同的 GTX 1080 上耗时约 440 毫秒:

调用

KV_swap_evicted

KV_swap_restored

发生了什么

code
DECODE smol …

none

false

冷启动。Manager 为 smol 构建了新的

code
llama_context

code
DECODE qwen …
code
smol

Manager 将 smol 的完整上下文状态序列化到主机的临时缓冲区中,释放了 smol 的上下文,构建了 qwen 的新上下文。

code
qwen

true

Manager 以同样的方式驱逐了 qwen,然后

从主机的临时缓冲区中恢复了 smol 的保存的 KV

到一个新的上下文中,以便对话继续。

在稳态下,当两个模型都已注册,并且 DECODE 调用频繁进行时,GPU 上实际占用的内存情况如下:

为两个已注册的小模型和一个实时上下文,占用了 926 MiB 的内存 —— 远低于 7.7 GiB 的预算。lmxd 进程是卡上唯一的 CUDA 租户。被挂起的代理不占用任何 VRAM 字节;它们的 KV 状态存储在主机的 RAM 中,直到它们重新获得实时的插槽。这就是“注册许多,解码任意”之所以便宜的原因。

对于线路上的怀疑者,一个 DECODE 响应的结构如下:

code
OK schema=lmx-daemon/1
OK invoke=DECODE agent_id=smol
OK prompt_tokens=4 generated_tokens=24 stopped_on_eos=false
OK elapsed_ms=125.495
OK kv_swap_evicted=qwen kv_swap_restored=true
BEGIN_RESPONSE
 [Weather]. I'm looking forward to seeing you all. I'm [Your Name] and I'm a [Your
END_RESPONSE

kv_swap_evicted / kv_swap_restored 这两行就是整个操作流程。如果一个 DECODE 调用在 125 毫秒内完成,并且该行显示 kv_swap_restored=true,那么你就确切知道两件事:守护进程进行了一次 PCIe 往返以恢复对话,并且你刚刚交谈的代理在调用之前已经被挂起(而不是被终止,也不是因为内存不足)。

坦白承认

在此,我应该坦白:我并不是一个“GPU 专家”。我的训练背景是在电信领域 —— 5G,甚至开始涉足 6G 的研究 —— 每一个“基础设施”问题在代理 AI 中看起来都像是我们多年前在无线电层已经解决的问题。

简短的版本是:当你的手机想要在蜂窝网络上建立一个新通话(或新数据会话)时,基站不会只是说“好的,你已连接”。它会运行连接接纳控制(CAC)——这个蜂窝网络是否可以在不破坏已连接会话的 SLA 的情况下接纳这个新会话?如果可以,就接纳;如果不行,就在建立时以明确的原因代码拒绝。从不静默地终止一个正在进行的通话以腾出空间给新的通话。

将这两个并排比较,告诉我这些是否是不同的问题:

5G 基站(连接接纳控制)

code
lmxd

在 GPU 上(VRAM 账户)

蜂窝容量 = 可用的无线电资源

设备容量为

code
vram_total_bytes

的 90%

已接纳的会话消耗已知的资源预算

已接纳的代理消耗已知的

code
ledger_allocated_bytes

新会话到达时带有预估的请求

新代理到达时带有

code
table_bytes

来自 VRAM 表

仅当

code
existing + new ≤ cell budget
code
allocated + new ≤ ledger_max_bytes

时才接纳

决策发生在

建立承载之前

任何 GGUF 加载之前

拒绝路径不会影响现有调用

拒绝路径不会影响现有代理

跳过 CAC → 单元崩溃,所有人被接纳,但无人被服务

跳过账本 → 所有人竞争

code
cudaMalloc

,只有一个能存活

自 3G 以来,每个蜂窝塔的 MAC 调度器一直在做出这个决定。如果你提出一个 LTE 系统,其中每部手机在到达时都被接纳,而调度器“之后再处理”,你将被礼貌地请出 3GPP 会议。然而,这正是每个“启动三个 llama-cli 进程并希望”演示在 8 GB GPU 上所做的。在新的动物园里,是同一种动物。

最后一个技巧 —— 仅加载你实际需要的层

让我们花点时间思考一下数字。假设有三个代理想要三个不同的 LLM,每个大约 4 GB,使用 4 位量化,总共在磁盘上需要大约 12 GB。你的显卡只有 8 GB。天真地加载“所有三个模型,始终完全驻留”注定会失败。但这并不是任何一毫秒内前向传递实际需要的。Transformer 解码步骤一次只接触一层。因此,如果你只将一层 Transformer(约 1.5 GB)放入 VRAM,加上 CUDA 上下文(约 500 MB),加上当前的 KV 缓存块,那么在任何一毫秒内,你的占用空间永远不会超过 3–4 GB,而其他 14 GB 的权重则存储在固定主机 RAM 中,等待轮到它们。

关键在于,“等待轮到它们”不能意味着“在我们通过 PCIe 获取下一层时,让 CUDA 核心空转”。在 GTX 1080 的 PCIe 3.0 ×16(约 12 GB/s 实际速度)上,读取一个 1.5 GB 的层需要约 125 毫秒。如果你按顺序等待,你将构建世界上最慢的 LLM。这个技巧 —— 真正唯一的技巧 —— 是让第 N 层的计算与第 N+1 层的传输重叠,使用两个不同的 CUDA 流,这样当核心完成乘法运算时,下一层的权重已经存放在预分配的交换槽中。指针交换。重复。永远。

一个固定页锁定的主机区域( cudaHostAlloc ),两个设备端乒乓缓冲区,两个 CUDA 流,每层的 cudaEvent 时间,以及热循环:

code
for (int i = 0; i < cfg_.n_layers; ++i) {
    // 步骤 1:在 transfer_stream 上使用 cudaMemcpyAsync 从主机到设备,然后同步。没有重叠。
    check_cuda("cudaEventRecord(transfer_start.serial)",
               cudaEventRecord(impl_->ev_transfer_start[i], impl_->transfer_stream));
    check_cuda(
        "cudaMemcpyAsync(serial)",
        cudaMemcpyAsync(d_curr, impl_->pool.slot_ptr(static_cast<std::size_t>(i)),
                        cfg_.bytes_per_layer, cudaMemcpyHostToDevice, impl_->transfer_stream));
    check_cuda("cudaEventRecord(transfer_end.serial)",
               cudaEventRecord(impl_->ev_transfer_end[i], impl_->transfer_stream));
    check_cuda("cudaStreamSynchronize(transfer.serial)",
               cudaStreamSynchronize(impl_->transfer_stream));

// 步骤 2:在 compute_stream 上启动计算内核,并在下一次迭代前进行同步。 check_cuda("cudaEventRecord(compute_start.serial)", cudaEventRecord(impl_->ev_compute_start[i], impl_->compute_stream)); layer_compute_kernel<<<n_blocks, kBlock, 0, impl_->compute_stream>>>( static_cast<const float*>(d_curr), static_cast<float*>(impl_->d_output), impl_->elements_per_layer, cfg_.compute_iters); check_cuda("kernel launch error (serial)", cudaGetLastError()); check_cuda("cudaEventRecord(compute_end.serial)", cudaEventRecord(impl_->ev_compute_end[i], impl_->compute_stream)); check_cuda("cudaStreamSynchronize(compute.serial)", cudaStreamSynchronize(impl_->compute_stream)); }

code

流 A(计算)因繁重的工作而“出汗”;流 B(传输)则在背后悄悄地将下一层数据搬运过去;GPU 上的任何部分都不会空闲等待总线。这两个 cudaStreamSynchronize 调用是每层唯一的真正串行点,在一个调优良好的流水线中,它们是无操作(no-ops)——流 B 在大约 30 毫秒前就完成了传输,并一直在等待流 A 捕上。

一个独立的 CLI 工具,layer_stream_demo,会在相同的硬件、相同的内存分配、相同的合成权重上,与一个严格串行的基线(先传输后计算,一次只使用一个流)并行运行这个循环,并将两个墙钟时间并排打印出来。在默认配置(8 层 × 64 MiB × 每个元素 64 FMA 迭代)的 GTX 1080 参考机上,它报告如下:

HEADLINE: serial=151.41 ms, overlapped=117.85 ms, savings=22.17%, speedup=1.285x

code

将配置推向带宽限制的极端(--n-layers 16 --bytes-per-layer 67108864 --compute-iters 64),节省比例可上升至约 32%(1.47 倍加速)。为了完全透明,这里测试的每层内核是一个具有代表性成本的 FMA 扫描。诚实地说,这个测试的范围是证明异步重叠模式在真正的硅芯片上有效,而不是声称我们已经重写了 llama.cpp 的整个解码图。将这个合成内核替换为完整的变压器前向传递是一个多月的工作;这个仓库提供的只是使未来任务成为可能的裸金属 C++ 原语。

这个技巧的第二部分直接归功于 SwarmKV 的架构。当你从模型 1 切换到模型 2 时,KV 缓存的几何形状完全改变——不同的头数、不同的维度、不同的数据类型。为了生存,协调器必须在模型 1 的前向传递完成的那一刻,将活动的 KV 块序列化回主机 RAM,为模型 2 释放 VRAM。

这正是相同的 llama_state_get_data → 主机缓冲区 → llama_state_set_data 的流程,只是在不同的模型之间运行,而不是同一模型的不同分支。我们将这个原语作为 lmx::KvSwapHelper 提供,它是对 llama.cpp 状态序列化 API 的无状态包装器。当上下文管理器在每次跨代理 DECODE 操作时执行此操作时,它会生成你上面看到的 kv_swap_evicted 和 kv_swap_restored 日志。这就是为什么这些数字是机械现实,而不仅仅是愿望。

## 如何尝试,哪些在范围内,哪些不在

好了,现在是时候讨论你如何在日常生活中实际使用这个解决方案了。

> GitHub 仓库链接:https://github.com/AnubhabBanerjee/VRAM-Conductor

我称它为“VRAM Conductor”,因为它在高峰时段的作用就像公交车司机一样:它检查车票,安排谁可以登上GPU,并在整辆车倾覆之前主动告诉下一位乘客“抱歉,车已经满了”。另外,我另一个备选名称“阻止三个AI代理为了最后1MB内存而互相刺杀的门卫”太长了,不适合用作GitHub的URL。

如果你想在自己的显卡上重现这个:

git clone <repo> && cd <repo-dir> cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DLMX_WITH_LLAMA_CUDA=ON cmake --build build -j"$(nproc)"

独立重叠演示(不需要模型文件):

./build/src/layer_stream_demo

守护进程。一旦启动,每个REGISTER都会被接受,每个DECODE都会运行真实的llama.cpp,并在每个代理切换时透明地进行KV交换:

./build/src/lmxd --socket /tmp/lmxd.sock --vram-table configs/model_vram.example.txt & nc -U /tmp/lmxd.sock <<EOF REGISTER smol /home/you/models/SmolLM2-360M-Instruct-Q4_K_M.gguf REGISTER qwen /home/you/models/Qwen2-0.5B-Instruct-Q4_K_M.gguf EOF printf 'DECODE smol 24 Hello, my name is\n' | nc -U /tmp/lmxd.sock printf 'DECODE qwen 24 The capital of France is\n' | nc -U /tmp/lmxd.sock printf 'DECODE smol 24 The weather today is\n' | nc -U /tmp/lmxd.sock

code

需求是显而易见的:Linux、CUDA工具包、NVML、NVIDIA GPU(Pascal或更新的型号)。守护进程需要你的VRAM表中的GGUF路径确实存在;流式演示则使用合成权重,只需要工具包即可。

在有人开始批评之前,这个仓库不声称的诚实列表如下:

- LayerStreamer的每层内核是一个代表成本的FMA扫描,而不是真正的Transformer层前向传递。将其替换为在我们的流式编排下运行llama.cpp的量化矩阵乘法内核,需要重新实现该矩阵乘法堆栈以使用流式权重指针,或者对llama.cpp的图执行器进行手术——两者都需要数月时间,且明确超出本仓库的范围。本仓库的范围包括工程基础演示:固定主机+两个CUDA流+双缓冲交换在真实硅片上确实重叠,测量了实际的时钟节省,同一张卡上三个简单的llama完成过程甚至无法共享。DECODE路径确实运行真实的llama.cpp端到端——只是不会在解码循环中驱动流式器。

- 单个插槽上下文模型在GPU上串行化代理。一次只有一个llama_context是活跃的;其他代理的KV存储在主机RAM中。同时解码两个代理不在范围内——这需要上面提到的解码内部LayerStreamer的工作,这是本仓库的多月版本。

- 账本使用操作员提供的字节估计(此处:精确的stat(1)大小乘以1.5倍)。现在真实解码进入画面后,这些估计需要扩展以更精确地覆盖KV缓存、激活和CUDA缓存余量——可以作为每模型公式或首次DECODE后的在线测量。

- 守护进程在启动时仅采样一次NVML,之后仅通过自身的try_reserve / release漂移。STATUS中暴露了实时NVML供操作员使用,但不会用于检测守护进程运行期间其他进程的增长。生产堆栈会定时重新同步。

- 一次一个GPU、一个进程、一个客户端。多GPU部署和高扇出IPC不在范围内。

这些都不会改变头条新闻。

## 结束

说实话:2026 年大多数“单 GPU 多代理”演示并不是在进行聪明的内存调度。它们只是把三个进程扔到显卡上,然后闭上眼睛,希望一切能奇迹般地运行。

lmxd 并不是什么神奇的新 AI 算法。它只是一个拿着记事本的门卫。它采用了一个比第一款翻盖手机还要古老的连接准入控制概念,并通过一个 C++ 守护进程、一个严格的 VRAM 账本和一个共享后端将其连接起来。结果?三个不同的指令模型礼貌地共享一块 8 GB 的显卡,而不是为了争夺它而殊死搏斗。

结论很简单:拒绝不可能的工作,比优化可能的工作更有价值。你可以拥有世界上最先进的推测解码和 MoE 路由,但如果盲目地允许一个物理上无法在内存中容纳的第三个代理,这些技术也无能为力。设计良好的系统会在执行工作之前验证预算。

克隆仓库。运行守护进程。然后认真审视你自己的流水线,看看有多少是靠运气悄悄存活下来的。如果你到这里是想知道为什么把三个大语言模型扔到旧 GPU 上无法直接运行,恭喜你——你现在对硬件限制的理解,比大多数编写这些教程的人还要好。

现在去大声责备你的分配器吧。温柔地。

准入截图(Track A,01 – 04)和守护进程的 STATUS+LIST 面板(05)是 2026-06-20 捕获的实际 nvidia-smi 和 lmxd 转录的直接渲染。DECODE-with-KV-swap 面板(06)和稳定状态的 nvidia-smi 面板(07)(均来自 Track B Continued)来自 lmxd 守护进程在 2026-06-22 在同一块 NVIDIA GTX 1080 上运行的 llama.cpp 实际解码。面板 06 和 07 在渲染时添加了轻量级语法着色——绿色用于 kv_swap_* 证据行,蓝色用于 OK,黄色用于 $ 提示——以使交换过程更容易理解。两者都是对守护进程实际 stdout 的 PIL 渲染。

作者

查看 Anubhab Banerjee 的所有文章

c++

,

深入探讨

Gpu

推理

Llm

分享这篇文章

- 在 Facebook 上分享

- 在 LinkedIn 上分享

- 在 X 上分享

Towards Data Science 是一个社区出版物。提交你的见解,以触达全球受众,并通过 TDS 作者支付计划获得报酬。

更新 href 为你的实际提交 URL

为 TDS 写作

✦ 结束 CTA ✦