【免费下载链接】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.
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 类 总共只有一百多行,结构非常干净:
- 入队:调用线程把任务(含一个
promise)压入队列,唤醒条件变量,然后阻塞在future上(score())。 - 攒批:worker 线程醒来后,从队首开始按 token 预算组装批次,遇到第一个"装不下"的请求就停,不跳过(攒批循环),保证 FIFO 顺序,先来先服务。
- 执行:整批交给
model.score_batch()一次前向传播,把每个请求的结果(概率 + 排队耗时 + 推理耗时)通过 promise 回传。
这个设计里有两个关键参数:
| 参数 | 默认值 | 上限 | 含义 |
|---|---|---|---|
--batch-tokens | 384 | CALL_TOKENS= 1024 | 一批请求合起来最多读多少 token |
--warmup | 384 | WARM_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 的吞吐分析页:
| 并发客户端数 | 吞吐量 | 延迟中位数 |
|---|---|---|
| 1 | 8.7 req/s | 110 ms |
| 4 | 9.8 req/s | 390 ms |
| 8 | 10.1 req/s | 780 ms |
吞吐在 1 个客户端之后就基本不动,延迟随队列线性增长——这正是排队论(Little's law)的预期表现:模型已经是满负荷的,更多请求只是排队,而不是并行变快。
要观测排队情况,每个响应的Server-Timing响应头会拆分出parse / validate / tokenize / queue / inference / respond / total七段耗时(server.cpp),total与inference的差值就是队列时间;/health里的batched_requests计数器则记录有多少请求被合并读了。仓库自带压测脚本 bench_concurrent.py,可直接复现上表。
为什么"单线程"反而更快?
- CPU 前向是算力密集型的:同一模型多线程推理只会抢同一批核心,收益远小于开销;单线程 + 批量把算力利用率拉满。
- 零锁、行为确定:模型只有一个用户,内部完全不需要互斥,结果不受请求交错影响,便于回归测试。
- 并行留在可并行的地方:验证与分词天然可并行,就放在请求线程上,不占用宝贵的模型线程时间。
- 扩展靠横向:单进程约 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.
相关推荐
Trippy多线程探测原理:并发请求调度机制解析
Trippy多线程探测原理:并发请求调度机制解析 引言:网络诊断工具的并发挑战 在现代网络诊断中,传统的 traceroute 工具因单线程探测机制导致诊断效率
网络CLI运维gitsome中的并发处理:多线程API请求实现
gitsome中的并发处理:多线程API请求实现 在日常开发中,你是否遇到过GitHub API请求缓慢、批量操作耗时过长的问题?gitsome作为增强型Git
开发工具版本控制devalue与JSON.stringify对比分析:10个关键差异点详解
devalue与JSON.stringify对比分析:10个关键差异点详解 devalue 是一个轻量级 JavaScript 数据序列化库,官方定位只有一句话
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考