RISC-V端侧多模型并发调度实战:权重常驻、内存争用与降级路径的协同设计
1. 并发改变了资源分配的评价标准
此前讨论的时延分解与预算分配隐含一个前提:设备上只有一个模型运行,全部内存与算力由其独占。真实产品中这一前提通常不成立——典型的端侧应用会同时驻留唤醒词检测、语音识别、语义理解与图像处理等多个模型,且未必串行使用,语音输入与画面处理往往同时发生。
并发带来的并非性能按比例下降,而是资源分配逻辑的重构。单模型场景的目标是把一个模型的时延压到最低;并发场景的目标变成在固定内存与算力下让各模型分别满足各自的时延约束。前者得到的最优解在后者体系中可能完全失效:把全部内存让给一个模型换取其低时延,代价是其余模型无法运行。
由此得到并发场景的第一条原则:优化对象是资源分配方案,而非单个模型的执行效率。
2. 内存的三类构成与并发上限
端侧推理的内存占用可归入三类,其增长规律互不相同:
| 类别 | 决定因素 | 生命周期 |
|---|---|---|
| 权重常驻区 | 参数量 × 量化位宽 | 模型存活期全程 |
| 激活工作区 | 单次推理的中间张量峰值 | 单次推理期间 |
| KV Cache | 层数 × KV头数 × 序列长度 × 位宽 | 单个请求会话期 |
三类内存对并发的响应方式不同。权重与并发度无关,模型驻留即占用固定额度;激活工作区在同一模型的多路请求串行执行时可复用,与并发度的关系取决于内部并行实现;KV Cache 随并发请求数与上下文长度线性增长,是唯一随负载直接扩张的部分。
这一区别决定了两条工程结论。其一,并发上限由权重总量与 KV Cache 规模共同决定,其中权重是固定成本,启动后无法压缩。其二,KV Cache 是唯一可在运行期弹性伸缩的部分,因此内存回收的首选是调整上下文长度而非卸载权重——后者使模型彻底不可用,前者只是降低单次会话可承载的长度。
设设备可用于模型的内存为 M,常驻模型集合为 S,则准入条件可写为:
Σ_{i∈S} weight_i + Σ_{i∈S} act_i + Σ_{i∈S} (streams_i × seq_i × kv_unit_i) ≤ M
该式与算力无关。端侧的并发能力因此多由内存而非算力决定,与数据中心以算力为约束的情形相反——后者可通过批处理提高吞吐,内存受限的设备增加并发则只会触发换页或分配失败。
3. 权重常驻的三种形态
全量常驻。 所有模型权重一次性加载并长期驻留。优点是无加载延迟、时延可预测;代价是内存占用等于权重总和,可支持的模型规模被硬性限制。适用于模型集合固定、内存预算充足的产品。
按需换入。 仅驻留当前活跃模型的权重,其余落在存储介质上,切换时换入。内存占用降至单模型量级,代价是切换引入的加载延迟,其大小取决于权重体积与存储带宽——对数百 MB 的权重,该延迟通常远超推理本身。适用于切换频率低的场景。
多实例共享。 同一模型被多路请求共享一份权重,各请求各自持有激活工作区与 KV Cache。这是并发场景下收益最直接的形态:权重成本不随请求数增长,新增请求只增加请求态开销。实现上要求权重区只读且被多路请求共同引用,任何原地修改都会破坏共享前提。
三者的选择依据可归纳为一句话:权重是固定成本,请求态是可变成本。产品若需长期驻留多个模型,应压缩权重而非压缩请求态;若只需应对请求数波动,则应保证权重共享路径可用。
4. 隔离粒度约束调度形态
多模型并发要求内存隔离:一个模型的内存越界写入不能破坏另一个模型的状态。隔离能力由硬件提供,常见两个层级——物理内存保护以区域为粒度,地址翻译以页为粒度,二者的能力差异直接决定可采用的并发形态。
具备页级隔离时,多个模型可运行在同一地址空间的不同实例中,由操作系统按进程或线程调度,抢占、换出与内存超售均有现成机制,调度策略的选择空间较大。仅具备区域级保护时,隔离粒度粗,模型数量与内存布局须在部署期固定,运行时只能在既定分区之间切换,抢占式策略基本不可行。
不同定位的处理器在这两类能力上的取舍差异显著:面向实时控制的系列常只提供区域级保护,以换取确定性与面积效率;面向应用处理的系列则完整实现页级翻译。确认目标平台的隔离粒度,可从处理器系列的整体布局入手(参见玄铁官网),具体能力仍应以对应型号的规格手册为准。隔离粒度不足会直接排除一整类调度策略,因此应在方案设计早期确认。
5. 批处理在端侧的适用边界
数据中心常用的连续批处理(continuous batching)通过把多个请求的 decode 步骤合并为一次矩阵乘,将权重读取成本分摊到多个请求上。端侧是否适用,取决于两个条件。
其一是内存余量。批处理本身不显著增加权重占用,但多路请求的 KV Cache 同时驻留会线性抬高内存需求。内存紧张时,吞吐收益会被 KV Cache 引发的降级动作抵消。
其二是时延约束。批处理把多路请求绑定在同一执行周期内,完成时间取决于其中最慢的一路,单请求时延的尾部随之抬高。若产品对首Token时延有硬性要求,批大小应受尾部时延约束限制,而非按吞吐最优确定。
适用于端侧的折中是小批量的选择性合并:仅合并处于相同阶段且优先级相近的请求,批大小上限由 KV Cache 余量反推,不追求吞吐最优。
6. 内存不足时的分级降级路径
内存不足是并发场景的常态而非异常,方案必须定义确定的降级次序。次序可按"对用户体验的破坏程度递增"排列,逐级启用:
第一级:限制并发。 拒绝超出额度的新请求,已接纳的请求正常执行。影响仅限新请求的等待时间,破坏性最小。
第二级:缩短上下文。 降低低优先级模型允许的最大序列长度,按缩减量释放等比例的 KV Cache。影响体现在长输入的请求上。
第三级:换出权重。 卸载低优先级模型的权重,需要时重新加载。释放量最大,代价是该模型在换入完成前不可用。
第四级:降低精度。 以更低量化位宽重新加载模型,换取权重体积下降。释放量中等,但引入精度损失,且需预先准备多套量化版本。
四级动作的排序依据是可逆性:限制并发与缩短上下文在负载回落后可自动恢复,换出权重需重新加载,降低精度需重新部署。优先使用可逆手段,是系统在负载波动下保持稳定的关键。
7. 实战:准入控制与内存回收
下面的示例实现上述第一、二级降级:在固定预算下计算各模型的内存占用,超出时按优先级从低到高削减上下文长度,直到满足预算。示例假设优先级编号为 0..N-1 的连续取值,数值越小优先级越高。
#include <stdio.h>
#include <stddef.h>
#define N 4
typedef struct {
const char *name;
size_t weight; /* 权重常驻字节数 */
size_t act; /* 激活工作区,每模型一份 */
size_t kv_unit; /* 每路请求每 token 的 KV 字节数 */
int prio; /* 优先级,数值越小越高 */
int streams; /* 在途请求路数 */
int max_seq; /* 当前允许的上下文长度 */
int seq_floor; /* 可接受的最小上下文长度 */
} model_t;
static size_t kv_bytes(const model_t *m)
{
return (size_t)m->streams * (size_t)m->max_seq * m->kv_unit;
}
static size_t footprint(const model_t *m)
{
return m->weight + m->act + kv_bytes(m);
}
static size_t used_total(const model_t *m, int n)
{
size_t s = 0;
for (int i = 0; i < n; i++)
s += footprint(&m[i]);
return s;
}
/* 内存超出预算时,按优先级从低到高削减上下文长度 */
static void reclaim(model_t *m, int n, size_t budget)
{
for (int p = n - 1; p >= 0; p--) {
int idx = -1;
for (int i = 0; i < n; i++)
if (m[i].prio == p)
idx = i;
if (idx < 0)
continue;
while (used_total(m, n) > budget &&
m[idx].max_seq > m[idx].seq_floor) {
int old = m[idx].max_seq;
m[idx].max_seq = old / 2;
if (m[idx].max_seq < m[idx].seq_floor)
m[idx].max_seq = m[idx].seq_floor;
size_t freed = (size_t)(old - m[idx].max_seq) *
(size_t)m[idx].streams * m[idx].kv_unit;
printf("reclaim: %-6s seq %d -> %-4d freed %zu B\n",
m[idx].name, old, m[idx].max_seq, freed);
}
}
}
int main(void)
{
const size_t budget = (size_t)512 << 20; /* 设备可用于模型的内存 */
model_t m[N] = {
{"wake", (size_t)2 << 20, (size_t)1 << 20, 1024, 0, 1, 64, 32},
{"asr", (size_t)32 << 20, (size_t)6 << 20, 12*1024, 1, 2, 512, 256},
{"nlu", (size_t)110 << 20, (size_t)20 << 20, 24*1024, 2, 2, 256, 128},
{"vision", (size_t)280 << 20, (size_t)40 << 20, 8*1024, 3, 1, 128, 64},
};
printf("budget : %zu B\n", budget);
printf("before : %zu B\n", used_total(m, N));
reclaim(m, N, budget);
printf("after : %zu B\n", used_total(m, N));
for (int i = 0; i < N; i++)
printf(" %-7s seq=%-4d footprint=%zu B\n",
m[i].name, m[i].max_seq, footprint(&m[i]));
return 0;
}
编译运行后得到:
budget : 536870912 B
before : 541130752 B
reclaim: vision seq 128 -> 64 freed 524288 B
reclaim: nlu seq 256 -> 128 freed 6291456 B
after : 534315008 B
wake seq=64 footprint=3211264 B
asr seq=512 footprint=52428800 B
nlu seq=128 footprint=142606336 B
vision seq=64 footprint=336068608 B
结果有两点值得说明。初始占用 541130752 B 超出预算 4259840 B,触发回收。第一轮削减的是优先级最低的 vision,其上下文由 128 降至 64 仅释放 524288 B,缺口未补足——KV Cache 只占其足迹的小部分,权重才是主体,而权重无法通过此级手段释放。随后轮到 nlu,上下文由 256 降至 128 释放 6291456 B,预算得以满足。可见回收顺序按优先级排列,但实际释放量由各模型的 KV Cache 占比决定。
缺口更大时,第二级降级将耗尽全部模型的上下文削减空间,必须进入第三级。示例中 vision 的权重为 293601280 B,换出后可一次性释放绝大部分缺口,代价是该模型暂不可用。
8. 常见问题与成因
- 并发数增加后总时延劣化超出预期:检查 KV Cache 是否按请求数线性增长却未被计入预算,再确认激活工作区是否被错误地按请求数重复分配;
- 相同负载下内存占用波动明显:多为上下文长度的动态变化未纳入准入判定,导致余量估计失真;
- 模型偶发分配失败但平均占用未超限:查看峰值是否出现在请求切换瞬间,换入新权重与释放旧权重之间的窗口会短暂双倍占用;
- 高优先级模型反而先被降级:检查优先级比较方向,以及回收是否从数值最大者开始;
- 缩短上下文后精度异常:确认截断策略是保留首尾还是仅保留尾部,前者会破坏位置编码的连续性假设;
- 换出权重后首次请求时延骤增:属预期行为,应定义可接受的换入延迟上限,或改为在空闲时段预换入。
结语
端侧多模型并发的核心不在于提升单个模型的效率,而在于建立一套确定的资源分配与降级规则:以内存预算反推并发上限,以权重共享控制固定成本,以可逆优先的顺序定义降级路径。方案的复杂度来自规则的数量,稳定性则来自规则的确定性——负载波动时行为可预期的方案,比峰值性能更高但行为不确定的方案更有工程价值。
下一篇将讨论端侧设备长时运行时的功耗与热约束:动态调频对推理时延的影响、热节流触发后的性能衰减曲线,以及持续负载下的能效比评估方法。
浙公网安备 33010602011771号