☰
拆解jevOS架构:单线程调度器如何实现LLM请求并发
2026/10/14 18:41:54 网站建设 项目流程

【免费下载链接】jev

jevos: an open-source alternative to Jev and Microsoft-Decision-1. Yes/no decisions, choices and scores with probabilities, on your laptop's CPU.

项目地址:https://gitcode.com/gh_mirrors/je/jev
点击查看免费下载

jevOS 是 jev 开源项目的本地 LLM 决策引擎:在笔记本 CPU 上运行一个小模型,把一段文本和一个"是否"问题在 28–130 毫秒内回答成答案概率。本文拆解它的并发架构——单线程调度器 + 请求队列 + token 预算批处理,看它如何让多个并发 LLM 请求共享同一次前向传播,既快又稳。

整体架构:三层流水线的线程分工

jev serve的请求路径被切成三层,每层有不同的线程策略:

层源码位置线程模型职责
HTTP 层server.cpp每条连接一个线程(cpp-httplib)解析 JSON、校验、分词
调度层scheduler.hpp单个 worker 线程 + FIFO 队列攒批、按序调用模型
模型层model.cpp只运行在调度线程上OpenVINO INT8 前向,输出 P(yes)

核心思想一句话:模型只有一个用户。main.cpp 开头的注释 直接说明了这一点——所有模型调用都由一个调度线程发出,而校验(wire.cpp)和分词跑在各自请求的线程上。并发没有丢,只是被分配到了真正可以并行的地方。

HTTP 层:验证与分词并行跑在请求自己的线程上

每个请求进来后,在自己的线程上完成三件"轻活":解析 body、校验 wire 格式、把 state 和每个问题编码成 token。分词用的词表是只读的,所以多个请求可以同时分词、互不加锁——server.cpp 中的注释 原话是:"一个请求在分词时,另一个请求可以正在推理"。

分词结束后,请求被压缩成一个 ScoreRequest:它的所有问题提示词(jobs)、共享前缀(prefix)和长度估算tokens()。之后唯一的动作是scheduler.score()——把任务塞进队列,阻塞等待结果。

单线程调度器如何工作:一个队列与一个条件变量

Scheduler 类 总共只有一百多行,结构非常干净:

  1. 入队:调用线程把任务(含一个promise)压入队列,唤醒条件变量,然后阻塞在future上(score())。
  2. 攒批:worker 线程醒来后,从队首开始按 token 预算组装批次,遇到第一个"装不下"的请求就停,不跳过(攒批循环),保证 FIFO 顺序,先来先服务。
  3. 执行:整批交给model.score_batch()一次前向传播,把每个请求的结果(概率 + 排队耗时 + 推理耗时)通过 promise 回传。

这个设计里有两个关键参数:

参数默认值上限含义
--batch-tokens384CALL_TOKENS= 1024一批请求合起来最多读多少 token
--warmup384WARM_TOKENS= 384空闲时预热到多长的 prompt

定义见 model.hpp 的常量 与 main.cpp 的参数解析。

token 预算批处理:并发请求共享一次前向传播

为什么"一起读"比"分开读"快?因为模型是 stateless 前向:批量让矩阵乘法的行数变多,摊薄了每次调用的固定开销。main.cpp 里有一段实测注释:4 个 64-token 请求合批后总耗时-11%,3 个 120-token 的请求-16%;但请求太大时合批反而变慢,所以有预算上限。

这也解释了"单线程并发"的本质:它不是让请求并行执行,而是让多个请求并行地占满一次执行的计算——这正是 vLLM 之类系统在 GPU 上做 continuous batching 的 CPU 精简版。

空闲预热:没有请求时调度器在做什么

CPU 推理引擎按输入 shape 编译 kernel,某个 prompt 长度第一次出现要多花 3–10 毫秒。如果把这个代价留给第一个用户,首请求就会莫名变慢。

调度器用了一个巧妙的机制(scheduler.hpp 头部注释):

  • 队列空且安静超过IDLE_GRACE(500 毫秒)后,worker 不睡觉,而是执行一次"预热调用",把还没预热过的 prompt 长度(1–384)逐个编译好 kernel;
  • 预热期间来了真实请求,就等这一次调用结束再处理,等待通常小于 0.2 秒;
  • 稳定流量自己就会把常用长度"热"掉,因此不会被预热打断。

代价是一次性、发生在没人的时候;收益是所有常规长度的请求从第一次起就是全速。

模型层缓存:状态快照与分段读取

调度器之外,模型层还有两个复用优化,进一步压缩了重复流量的实际计算量:

  • 状态快照:SnapshotIndex 按 token 前缀匹配缓存 state 的中间激活,LRU 淘汰(默认 16 份、共 8192 个 state token,可用--state-cache调整)。同一客户消息被追问多个问题时,后续请求只读增量部分。
  • 分段读取:超长 state 按 PIECE_TOKENS = 2048 的块分段读,兼顾精度与耗时(分段大小的取舍分析在 model.hpp 的注释)。

实测数据:并发客户端下的吞吐与延迟

用 999 题集中的单问题请求压测参考机型(Intel Core Ultra 7 255H,16 线程,早期 jevos 版本),数据见 wiki 的吞吐分析页:

并发客户端数吞吐量延迟中位数
18.7 req/s110 ms
49.8 req/s390 ms
810.1 req/s780 ms

吞吐在 1 个客户端之后就基本不动,延迟随队列线性增长——这正是排队论(Little's law)的预期表现:模型已经是满负荷的,更多请求只是排队,而不是并行变快。

要观测排队情况,每个响应的Server-Timing响应头会拆分出parse / validate / tokenize / queue / inference / respond / total七段耗时(server.cpp),total与inference的差值就是队列时间;/health里的batched_requests计数器则记录有多少请求被合并读了。仓库自带压测脚本 bench_concurrent.py,可直接复现上表。

为什么"单线程"反而更快?

  1. CPU 前向是算力密集型的:同一模型多线程推理只会抢同一批核心,收益远小于开销;单线程 + 批量把算力利用率拉满。
  2. 零锁、行为确定:模型只有一个用户,内部完全不需要互斥,结果不受请求交错影响,便于回归测试。
  3. 并行留在可并行的地方:验证与分词天然可并行,就放在请求线程上,不占用宝贵的模型线程时间。
  4. 扩展靠横向:单进程约 1 GB 内存,一个进程饱和后,标准做法是多进程或多机 + 负载均衡,而不是在一个进程里加线程。

顺带一提,离线模式jev decide也复用了同一个调度器,只是关掉批处理与预热(main.cpp),接口行为完全一致。

关键源码导航

想自己动手翻代码,按这个顺序最省力:

  • 调度器全貌(队列、攒批、预热):src/scheduler.hpp
  • HTTP API 与计时:src/server.cpp
  • 批处理/分段/预热常量及取舍依据:src/model.hpp
  • 请求单元结构:src/tokens.hpp
  • 状态快照缓存:src/snapshots.hpp
  • 并发压测脚本:tests/bench_concurrent.py
  • 吞吐 vs 延迟的完整分析:wiki/throughput-vs-latency-for-a-decision-server.md

jevOS 用不到两百行的调度器证明了一件事:在 CPU 上跑本地 LLM 服务,并发的关键不是"更多线程",而是把线程花在可并行的地方,把算力花在攒批上。

【免费下载链接】jev

jevos: an open-source alternative to Jev and Microsoft-Decision-1. Yes/no decisions, choices and scores with probabilities, on your laptop's CPU.

项目地址:https://gitcode.com/gh_mirrors/je/jev
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询