目录

bananaOS:面向 RISC-V Vector 的大语言模型并发推理调度优化

项目简介

bananaOS 在真实 RISC-V Vector(RVV)平台上研究大语言模型并发推理时的向量寄存器状态保存开销。项目以 llama.cpp 的 llama-server 为负载,使用内核探针测量 V 状态保存次数与耗时,通过 Linux sched_ext 识别近期使用 RVV 的 V-hot 线程,并结合 Prefill/Decode 阶段提示调整时间片、虚拟运行时间和 CPU 放置。实验框架将内核观测、调度日志、TPS 和首 Token 延迟按同一 run_id 汇总。

应用场景:搭载 RVV 1.0 的 RISC-V 设备在 CPU 上同时处理多路量化模型推理请求;也可用于研究向量密集型任务的调度与上下文切换开销。项目的主要验证平台为 BananaPi BPI-F3(SpacemiT K1)。

主要功能:

  1. vstate_profiler 内核模块与 eBPF observer 采集 V 状态保存、调度切换和迁核数据。
  2. scx_vstate 根据 VS=Dirty 历史识别 V-hot 线程,配置时间片、vtime 权重与 CPU 局部性;服务端还可通过 BPF Map 声明 Prefill、Decode、Mixed 阶段。
  3. 自动启动推理服务、发起 2/4/8 并发流式请求,计算 TPS、平均 TTFT、P95 TTFT,并保存单轮 JSON、日志与汇总 CSV。
  4. 提供 x86 AVX2 状态规模与上下文切换的机制对照工具。

详细设计见技术报告和项目创新说明;包含完整参数、故障排查与复现实验矩阵的操作手册见完整构建与运行说明。

目录结构

路径 内容
src/llama.cpp/ 加入阶段提示的 llama.cpp 推理服务源码
src/sched_ext/vstate/ 当前主线 scx_vstate 调度器及加载器,阶段提示 ABI v3
src/dynamic_sched_ext/、src/long_time_sched_ext/ 动态阶段调度与长时间片策略的实验版本
src/os_module/ V 状态保存路径观测内核模块
src/rvv-llm-lab/ 单轮实验入口、eBPF observer、请求发生器和结果分析
src/rvv_llm_lab/ 批量实验编排与交叉构建辅助脚本
src/x86_avx2/ x86 AVX2 机制对照工具
tests/rvv_llm_lab/result/ 已保存的板端实验记录
docs/、presentation/ 项目文档与演示材料

以下命令均从项目根目录执行。迁移仓库后建议重新配置 CMake 构建目录;模型路径、内核树和工具链路径通过变量设置,不依赖某个用户的主目录。

系统环境与依赖要求

项目 已验证配置/要求
运行硬件 BananaPi BPI-F3,SpacemiT K1 八核 RISC-V,RVV 1.0,VLEN=256 bit
板端系统 Armbian;内核 6.18.37-current-spacemit,启用 sched_ext、BPF、BTF、kprobe 和 debugfs
实验负载 GGUF 量化模型;正式评测采用 Qwen2.5-0.5B-Instruct Q4_K_M
开发/构建主机 Linux 或 WSL2;与板端运行内核一致的源码树及 vmlinux,板端 BTF,RISC-V 64 位 GNU 交叉工具链
基础工具 GCC/G++、CMake、Ninja、Make、Python 3、Clang/LLVM(含 BPF 后端)、bpftool、libbpf、libelf、zlib;Python aiohttp

在 Debian/Ubuntu/Armbian 上可准备常用构建依赖:

sudo apt update
sudo apt install -y build-essential cmake ninja-build pkg-config \
  clang llvm bpftool libbpf-dev libelf-dev zlib1g-dev \
  python3 python3-aiohttp

板端先确认内核能力:

uname -r
test -r /sys/kernel/btf/vmlinux
test -e /sys/kernel/sched_ext/state

内核模块和调度器需要与实际运行的内核匹配。KERNEL_SRC 应指向对应内核源码树,且该树包含带符号的 vmlinux;VMLINUX_BTF 应指向从板端 /sys/kernel/btf/vmlinux 取得的 BTF 文件。两者用途不同。

构建方法

1. 在 Linux/WSL2 构建 RISC-V 产物

先设置本机路径,再执行项目提供的构建命令:

cd /path/to/bananaOS
export RISCV_ROOT_PATH=/path/to/riscv64-toolchain
export KERNEL_SRC=/path/to/matching-kernel-tree
export VMLINUX_BTF=/path/to/board-vmlinux.btf

# 带 RVV 和阶段提示的 llama-server
bash src/rvv_llm_lab/scripts/build_llama_bpi.sh

# 保存路径观测模块;生成与目标 vmlinux 匹配的探针偏移
make -C src/os_module KERNEL_SRC="$KERNEL_SRC" TOOLCHAIN="$RISCV_ROOT_PATH" -j"$(nproc)"

# BPF 调度器、skeleton 与 RISC-V 静态加载器
make -C src/sched_ext/vstate KERNEL_SRC="$KERNEL_SRC" \
  VMLINUX_BTF="$VMLINUX_BTF" TOOLCHAIN="$RISCV_ROOT_PATH" deps
make -C src/sched_ext/vstate KERNEL_SRC="$KERNEL_SRC" \
  VMLINUX_BTF="$VMLINUX_BTF" TOOLCHAIN="$RISCV_ROOT_PATH" -j"$(nproc)"

生成的主要文件是 src/llama.cpp/build-bpi-rvv/bin/llama-server、src/os_module/vstate_profiler.ko 和 src/sched_ext/vstate/build/scx_vstate。调度器的 deps 目标下载并解包 RISC-V 静态库;已有目标架构依赖时,也可通过 TARGET_DEPS_SYSROOT 指定 sysroot。构建细节及板端原生构建 llama.cpp 的方法见完整构建与运行说明。

2. 在 RISC-V 开发板构建观测与压测工具

先将项目源码复制到开发板,再在板端的项目根目录执行:

make -C src/rvv-llm-lab

产物为 src/rvv-llm-lab/bin/vstate_observer、src/rvv-llm-lab/ebpf/vstate_observer.bpf.o 和 src/rvv-llm-lab/bin/rvv_vlenb。可执行 src/rvv-llm-lab/bin/rvv_vlenb 检查向量寄存器字节数;K1 的预期值为 vlenb=32。

安装与板端准备

将完整项目目录复制到开发板的任意位置,并把交叉构建产物放回板端项目中的相同相对路径。如果直接在板端构建对应组件,这一步无需复制该组件的产物。例如,在构建主机的项目根目录、且板端已有项目源码时,可这样同步三个交叉构建产物(替换板端地址与目录):

BOARD=user@board-address
BOARD_ROOT=/path/to/bananaOS
ssh "$BOARD" "mkdir -p '$BOARD_ROOT/src/llama.cpp/build-bpi-rvv/bin' '$BOARD_ROOT/src/sched_ext/vstate/build'"
scp src/llama.cpp/build-bpi-rvv/bin/llama-server \
  "$BOARD:$BOARD_ROOT/src/llama.cpp/build-bpi-rvv/bin/"
scp src/os_module/vstate_profiler.ko "$BOARD:$BOARD_ROOT/src/os_module/"
scp src/sched_ext/vstate/build/scx_vstate \
  "$BOARD:$BOARD_ROOT/src/sched_ext/vstate/build/"

另行准备已获使用许可的 GGUF 模型,设置其实际路径;模型文件不包含在仓库中。

在板端项目根目录挂载所需文件系统并加载模块:

sudo mount -t debugfs none /sys/kernel/debug 2>/dev/null || true
mountpoint -q /sys/fs/bpf || sudo mount -t bpf bpf /sys/fs/bpf
sudo insmod src/os_module/vstate_profiler.ko
sudo cat /sys/kernel/debug/vstate_profiler/stats

run_exp.sh 会控制已加载模块的采样窗口,并按轮启动/停止受管调度器;模块本身由上述 insmod 命令加载。正式对照实验还需将各 CPU policy 的 governor 固定为 performance:

for f in /sys/devices/system/cpu/cpufreq/policy*/scaling_governor; do
  echo performance | sudo tee "$f" >/dev/null
done

运行方法

在开发板项目根目录设置公共参数。MODEL 应指向板端实际存在的 GGUF 文件,LLAMA_SERVER 和 SCX_BIN 指向板端构建产物:

cd /path/to/bananaOS
export PROJECT_ROOT="$PWD"
export LLAMA_SERVER="$PROJECT_ROOT/src/llama.cpp/build-bpi-rvv/bin/llama-server"
export SCX_BIN="$PROJECT_ROOT/src/sched_ext/vstate/build/scx_vstate"
export MODEL=/path/to/qwen2.5-0.5b-instruct-q4_k_m.gguf
export THREADS=8 THREADS_BATCH=8 CTX_SIZE=4096
export CONCURRENCY=2 PARALLEL=2 REQUESTS=16
export FIXED_PROMPT_WORDS=64 MAX_TOKENS=64
export SCX_STICKY_MODE=adaptive SCX_PHASE_POLICY=proportional

实验脚本会启动 llama-server,等待 /health 就绪,启动 observer 和可选调度器,发送流式推理请求,然后保存结果并清理本轮进程。三种运行模式:

# Linux 默认调度基线
RUN_ID=baseline-c2-r1 ENABLE_SCX=0 ENABLE_PHASE_HINTS=0 \
  bash src/rvv-llm-lab/scripts/run_exp.sh

# 仅使用内核 Dirty/V-hot 反馈
RUN_ID=vhot-c2-r1 ENABLE_SCX=1 SCX_MANAGED_PARTIAL=1 ENABLE_PHASE_HINTS=0 \
  bash src/rvv-llm-lab/scripts/run_exp.sh

# V-hot 与 llama.cpp 阶段提示协同调度
RUN_ID=cooperative-c2-r1 ENABLE_SCX=1 SCX_MANAGED_PARTIAL=1 ENABLE_PHASE_HINTS=1 \
  bash src/rvv-llm-lab/scripts/run_exp.sh

脚本默认启用 vstate_profiler 采样,运行前需完成上面的模块加载。每轮输出位于 src/rvv-llm-lab/results/<run_id>/,跨轮摘要追加到 src/rvv-llm-lab/results/summary.csv。需要不同模型、线程、并发或路径时,覆盖相应环境变量即可。

测试与验证方法

  1. 构建检查:确认 llama-server 与 scx_vstate 为 RISC-V 可执行文件,并确认模块的 vermagic 与板端 uname -r 一致。
  2. 单轮冒烟:先以 REQUESTS=2 MAX_TOKENS=16 运行 Baseline,再运行协同调度组;检查 loadgen_result.json 的 error_count、observer.json、vstate_profiler_stats_raw/debugfs_stats.txt 和 scx_vstate.log。
  3. 正式对照:保持模型文件、量化版本、线程数、输入分布、CPU governor 一致;将 CONCURRENCY 与 PARALLEL 同设为 2、4、8,每种策略和并发组合独立运行至少 3 次。对 TPS、平均 TTFT、P95 TTFT、V 状态保存次数和保存耗时计算均值及样本标准差。

例如,检查一次冒烟结果:

RUN_ID=smoke-baseline-c2 REQUESTS=2 MAX_TOKENS=16 \
  ENABLE_SCX=0 ENABLE_PHASE_HINTS=0 \
  bash src/rvv-llm-lab/scripts/run_exp.sh

python3 -m json.tool \
  src/rvv-llm-lab/results/smoke-baseline-c2/loadgen_result.json >/dev/null
tail -n 2 src/rvv-llm-lab/results/summary.csv

仓库中的既有实验记录提供每轮配置、请求输出、内核观测与汇总 CSV;具体的多轮统计和排查命令见完整构建与运行说明。

以下是技术报告

面向 RISC-V Vector 扩展的大语言模型推理并发调度优化技术报告

1-6赛题题目:面向 RISC-V Vector 扩展的大语言模型推理并发调度优化(高校赛题)

参赛小组:组里都飞升了

目录:

1、赛题背景及赛题要求

1.1 赛题背景

​ RISC-V Vector 扩展(RVV 1.0)已被纳入 RVA23 Profile 强制规范(2024 年 10 月批准),正在成为 RISC-V 处理器的标准配置。2025 年初,llama.cpp 等主流大语言模型(LLM)推理框架完成了 RVV 1.0 适配,使得在 RISC-V 设备上运行 LLM 成为现实。

​ 然而,RVV 的引入带来了值得单独研究的操作系统调度挑战。x86 AVX-512 与 ARM SVE 等架构同样存在扩展状态上下文管理问题,但 RVV 的变长寄存器(VLEN 由实现定义,最大可达数千 bit)与 Vector Length Agnostic(VLA)设计使其系统代价与管理复杂度显著不同。在RISC-V 架构中,每个 hart 拥有 32 个向量寄存器(v0–v31),单次完整 V 状态保存需要512 字节至 1024 字节以上的内存写入。Linux 通过 VS 状态位配合惰性上下文管理策略(lazy save / restore)降低不必要的状态切换开销,但在多向量密集型线程并发运行时,当向量状态为 dirty 的线程被抢占,仍会触发 V 寄存器组的完整保存,在 LLM 推理这类高强度持续向量计算场景下积累出不可忽视的调度开销。

​ LLM 推理是典型的高强度持续向量计算场景:prefill 阶段执行大规模矩阵乘(GEMM),decode阶段执行矩阵向量乘(GEMV),两个阶段均需长时间持续占用向量寄存器。在推理服务器同时处理多个并发请求时,Linux 默认调度策略(CFS)对向量密集型推理线程与普通线程一视同仁,导致推理线程在计算关键路径上被频繁抢占,积累的 V 状态切换开销造成整体吞吐下降与尾延迟(P99 TTFT)恶化。

​ 目前,学术界和工业界均缺乏专门针对 RISC-V V 状态感知的 LLM 推理调度策略,这是一个兼具理论价值与工程意义的开放问题。本赛题以 RISC-V Vector 扩展为实验平台,其核心关注点——具有高代价扩展状态的处理器在 AI 推理负载下的 OS 调度协同——同样适用于 x86 AMX/AVX-512、ARM SVE2 等架构,参赛成果具备向其他平台迁移的理论基础。

1.2 赛题要求

1、基于 openEuler、openKylin、OpenHarmony 等至少一个国内主流开源操作系统开发,鼓励在更多Linux发行版上编译、运行和测试。

2、问题量化(必须完成) 在 RISC-V RVV 1.0 环境下,可采用内核 tracepoint、kprobe、eBPF 程序、perf/PMU 或其他等价、可复现的系统观测手段,对 V 状态保存/恢复路径插桩(如挂载内核函数 riscv_vstate_save),量化多并发 LLM 推理场景下 V 状态上下文切换的实际开销,建立其与并发请求数、模型规模之间的量化关系,分析该开销对推理吞吐与延迟的影响。若实验平台 PMU 支持相关硬件事件,可作为辅助验证手段,但非必要。

3、调度优化实现(必须完成,以下策略为推荐参考路径,允许使用其他等价的OS 调度优化机制,至少完成一种)

a. 结合 CPU affinity 与 cgroup cpuset 策略,将推理服务的工作线程池统一绑定至指定核心集合(llama.cpp 可通过 --cpu-mask / --cpu-range 配合 -t / -tb 参数进行线程与 CPU 绑定控制),降低线程迁移与抢占导致的 V 状态保存/恢复频率(入门级方案,同等有效);

b. 基于 sched_ext(Linux 6.12+ 的 BPF 可扩展调度框架)实现 V 状态感知调度器,或基于 sched_setattr / affinity / cpuset 等现有接口对持有 Dirty V 状态的推理线程进行调度提示与放置优化,减少同一时间窗口内的 V 状态切换次数(进阶方案);

4、性能评测(必须完成)

​ 以 llama.cpp server 模式为基准,使用不超过 3B 参数量的量化模型(如 Llama-3.2-1B/3B Q4_K_M),在 2、4、8 并发请求场景下,采用统一基线配置(相同模型与量化版本、相同 --threads 参数、相同输入长度分布、CPU 频率策略固定为 performance governor),每组配置至少重复 3 次,报告均值与标准差,对比优化前后的推理吞吐(TPS)、平均 TTFT 与 P95 TTFT(毫秒)。性能评测数据须来自真实 RVV 1.0 硬件。

5、可选完成项

  • 探索用户态-内核调度提示接口设计(如通过 sched_setattr 扩展 flag 或自定义 ioctl),允许推理框架向调度器声明计算阶段,不限定接口形式;

  • 与 x86(AVX2/AVX-512)平台进行横向对比,定量分析 RVV VLA 设计在V 状态切换问题上的特殊性;

  • 向 llama.cpp 或 Linux 内核上游社区提交相关补丁或 RFC,附上链接。

2、项目设计

2.1 技术落地可行性方案

2.1.1 eBPF 技术

​ eBPF(extended Berkeley Packet Filter)是 Linux 内核提供的安全可编程执行机制,允许用户在受验证和受限制的特权上下文中运行沙盒化程序。用户态程序可以将小型 eBPF 程序动态加载到内核中,并在系统调用、函数入口/出口、内核跟踪点、网络数据包到达等事件触发时执行。eBPF 源于经典 BPF(Berkeley Packet Filter),其应用范围已从网络包过滤扩展到可观测性、安全、性能分析和内核策略扩展等领域。

​ 在传统内核扩展方式中,开发者通常需要编写内核模块,或修改内核源码后重新编译和部署内核。前者具有较高权限,一旦实现错误可能影响系统稳定性;后者维护成本高,迭代周期长。eBPF 提供了另一种工程路径:在不修改内核源码、不重新构建内核的前提下,通过受验证程序向内核注入有限逻辑。所有 eBPF 程序在加载前均需通过内核验证器检查,包括循环边界、非法指令和内存访问安全等约束;加载后,eBPF 字节码可由 JIT 编译器转换为目标平台原生机器指令,从而兼顾安全边界和执行效率。

​ eBPF 程序的开发与运行流程包括:使用受限 C 子集编写内核侧逻辑,通过 LLVM 编译为 eBPF 字节码,再通过 bpf() 系统调用加载到内核。加载阶段由验证器进行安全审查,通过验证后由 JIT 编译器生成原生机器码。eBPF 程序与用户态程序之间通常通过 eBPF Map 交换数据;Map 是内核中持久存在的键值对数据结构,可由 eBPF 程序读写,也可由用户态程序通过系统调用访问。

image-20260703095756517

​ 本项目使用固定在 bpffs 中的 eBPF Map(/sys/fs/bpf/scx_vstate/phase_hints)作为 llama.cpp 与 sched_ext 调度器之间的共享键值接口。用户态在推理阶段变化时,将计算线程 TID、PREFILL/DECODE 阶段、Token 数量及更新时间写入 Map;运行在内核态的调度器根据当前任务查询提示,动态调整时间片、调度优先级和 CPU 粘性。

​ 提示超过 TTL 后自动失效,调度器退回基于实际 RVV 活跃度的 V-hot 策略,避免错误提示长期影响调度。该方案无需修改系统调用接口或重新编译内核,具有开销低、接口灵活、用户态与调度策略解耦等优点。

2.1.2 Linux 调度与 sched_ext 技术

​ Linux 内核调度器负责在多个任务之间分配 CPU 时间,是操作系统的核心子系统之一。Linux 维护了一套调度器类体系,主要包括停止(Stop)、实时(RT)、公平(CFS/EEVDF)和空闲(Idle)等调度类,其中公平调度类是多数通用场景下的默认选择。该体系在服务器和桌面等通用负载下具有较好的稳健性,但调度策略主要固化在内核代码中,实验新的调度算法通常需要修改内核源码、重新编译并部署内核,迭代和维护成本较高。

​ sched_ext(Extensible Scheduler Class)是 Linux 6.12 引入的 BPF 可扩展调度框架。其核心特征是将调度策略从固定内核实现中抽离出来,由一组 BPF 程序在运行时定义。sched_ext 导出任务选核、入队、分发、运行和停止等调度接口,使开发者能够在不修改内核源码、不重启系统的情况下加载和卸载实验性调度器。sched_ext 在内核中作为独立调度类存在;当 BPF 调度器未加载或退出时,系统可以回退到默认公平调度器。

image-20260703101439694

1、框架定义基础事件驱动:sched_ext 的工作流程由调度事件驱动。当内核调度器核心发生任务唤醒、CPU 空闲等事件时,会调用 sched_ext_ops 中对应的 BPF 回调函数,使用户定义的调度策略参与任务选择。主要回调函数包括:

  • **select_cpu**:任务被唤醒时调用,用于选择目标 CPU,是实现负载均衡和局部性策略的重要入口。
  • **enqueue / dequeue**:任务加入或移出调度队列时调用,可用于维护自定义队列和优先级结构。
  • **dispatch**:CPU 需要可运行任务时调用,负责从调度器队列中选择任务并分发到 CPU 本地 DSQ。
  • **tick**:调度器时钟 tick 到来时调用,可用于实现基于时间片的控制或检测任务运行时间。
  • **runnable / running / stopping**:分别在任务变为可运行、开始运行、停止运行时调用,用于跟踪任务状态。

通过实现这些回调,调度器可以控制任务排队位置、运行时机和运行时间等关键调度行为。

2、调度队列设计:sched_ext_ops 定义调度逻辑,任务队列由 Dispatch Queues(DSQ)承载。DSQ 将任务排队与具体 CPU 执行解耦,使 BPF 调度器能够根据策略将任务分发到不同 CPU。sched_ext 提供以下队列类型:

  1. 全局 DSQ(SCX_DSQ_GLOBAL):系统全局 FIFO 队列,所有 CPU 均可从中获取任务,适合作为基础共享队列模型。
  2. 本地 DSQ(SCX_DSQ_LOCAL):每个 CPU 对应一个本地 FIFO 队列,CPU 从本地 DSQ 取出任务执行;dispatch 回调负责将任务从其他队列移动到本地 DSQ。
  3. 自定义 DSQ:BPF 调度器可使用 scx_bpf_create_dsq() 创建自定义 DSQ,用于实现优先级队列、虚拟时间队列或负载分类队列等策略。

3、看门狗兜底:sched_ext 将系统完整性置于调度策略之上。BPF 调度器需要满足验证器约束,并接受运行时看门狗保护,主要机制包括:

  • 严格验证:所有 BPF 调度器在加载前均经过内核 BPF 验证器检查,确保内存访问和控制流满足安全约束。
  • 自动恢复(Bypass Mode):当 BPF 调度器触发错误、检测到任务停滞,或用户触发 SysRq-S 时,sched_ext 会中止当前 BPF 调度器,并将任务切回内核默认公平调度器。
  • 部分切换模式:通过设置 SCX_OPS_SWITCH_PARTIAL 标志,sched_ext 可仅调度显式标记为 SCHED_EXT 策略的任务,其他普通任务(SCHED_NORMAL 等)继续由默认公平调度类管理,适合在实验环境中控制影响范围。

2.1.3 llama.cpp 框架应用方案

​ llama.cpp 是基于 C/C++ 实现的开源大语言模型推理框架,设计目标是在资源受限设备上提供轻量化 CPU 推理能力。框架以 GGUF 量化模型文件作为主要模型载体,支持通过命令行工具和服务化组件完成推理调用。llama-cli 适合本地交互式推理,llama-server 提供与 OpenAI 兼容的 HTTP API,便于将推理能力接入现有应用系统。服务模式支持流式输出,实验框架可以据此记录首个非空输出片段的到达时间,并计算 TTFT。

输入提示: "The capital of France is"
输出: "Paris"

阶段一:预填充 (Prefill)
- 输入: ["The", "capital", "of", "France", "is"] (5个token)
- 计算: 并行处理所有token
- 输出: 第一个新token "Paris"
- 特性: 计算密集型 (GEMM运算为主)

阶段二:解码 (Decode)
- 输入: ["The", "capital", "of", "France", "is", "Paris"] (6个token)
- 计算: 逐个生成下一个token
- 输出: 后续token直到结束
- 特性: 内存密集型 (GEMV运算 + KV缓存访问)

​ llama.cpp 的部署依赖较少,不依赖 PyTorch、TensorFlow 等完整深度学习运行时,编译后可生成相对独立的二进制程序。模型量化是其降低内存和存储需求的重要机制:通过将模型权重从 32 位浮点数压缩至 4 位或 8 位整数,可以显著降低模型文件体积和运行时内存占用。框架同时支持多核并行计算,并针对 x86 AVX2/AVX-512、ARM NEON、RISC-V RVV 等 SIMD/向量扩展提供后端优化路径,适合作为本项目的 RISC-V LLM 推理负载。

┌─────────────────────────────────────────────────────────────┐
│                      应用层 (Applications)                   │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐        │
│  │llama-cli │ │llama-server│llama-embed│llama-perf│         │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘        │
└───────────────────────────┬─────────────────────────────────┘
                            │ llama.h API
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                   核心管理层 (Core Management)               │
│  ┌─────────────────────┐ ┌─────────────────────┐           │
│  │   llama_context     │ │   llama_model       │           │
│  │   (会话状态/调度)     │ │   (模型加载/元数据)  │              │
│  └─────────────────────┘ └─────────────────────┘           │
│  ┌─────────────────────┐ ┌─────────────────────┐           │
│  │   llama_batch       │ │   llama_kv_cache    │           │
│  │   (批次管理)         │ │   (键值缓存)          │          │
│  └─────────────────────┘ └─────────────────────┘           │
└───────────────────────────┬─────────────────────────────────┘
                            │ ggml API
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    GGML 张量计算层                           │
│  ┌─────────────────────────────────────────────────────┐   │
│  │            ggml_cgraph (计算图)                      │   │
│  └─────────────────────────────────────────────────────┘   │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐      │
│  │ 张量操作   │ │ 自动微分  │ │ 量化内核   │ │ 内存管理  │      │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘      │
└───────────────────────────┬─────────────────────────────────┘
                            │ 硬件后端抽象层
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                   硬件后端 (Hardware Backends)               │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐      │
│  │ CPU      │ │ CUDA     │ │ Metal    │ │ Vulkan   │      │
│  │(ARM/x86) │ │(NVIDIA)  │ │(Apple)   │ │(跨平台)   │      │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘      │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                   │
│  │ SYCL     │ │ OpenCL   │ │ Kompute  │                   │
│  │(Intel)   │ │(通用GPU)  │ │(Vulkan)  │                   │
│  └──────────┘ └──────────┘ └──────────┘                   │
└─────────────────────────────────────────────────────────────┘

​ 在 RISC-V 架构适配方面,llama.cpp 及其底层计算后端 ggml 已提供 RVV 相关支持。2025 年 3 月,RISC-V 128 位向量扩展支持补丁合入 ggml 主分支,框架正式具备 RVV 后端能力。在此基础上,社区围绕 RVV 1.0 扩展持续优化矩阵向量乘法等关键算子。公开结果显示,在 64 核 RISC-V 平台上,Prompt 处理吞吐量可从基准版本的 14.01 tokens/s 提升至 35.53 tokens/s,生成阶段吞吐量可从 8.74 tokens/s 提升至 17.33 tokens/s;在特定 RISC-V AI 核心上,Prompt 处理速度报告值可达到 111 tokens/s。此外,框架 CPU 后端针对 RISC-V 提供 GGML_RV_ZFH、GGML_RV_ZVFH 等编译选项,用于控制半精度浮点和向量相关特性的启用状态。这些适配工作说明 RISC-V 平台已具备承载大语言模型推理任务的工程基础。

image-20260703103944409

​ llama.cpp 内置了多项性能统计接口。通过 llama_perf 接口或 llama_perf_print 函数,可以获取当前推理上下文的计时与计数数据,包括模型加载耗时 t_load_ms、提示词处理耗时 t_p_eval_ms、token 生成耗时 t_eval_ms、已处理提示词 token 数 n_p_eval、已生成 token 数 n_eval 以及计算图复用次数 n_reused。基于这些原始数据,可以计算 prompt 处理速度和生成速度。在更底层的性能分析中,还可以结合 Linux perf 等工具采集缓存访问、指令执行和浮点/向量相关硬件事件,用于定位特定硬件平台上的计算热点和性能瓶颈。

image-20260703103723826

2.1.4 x86_64 到 RISC-V 的交叉编译技术

​ x86 到 RISC-V 的交叉编译是 RISC-V 应用开发中常用的工程方法。GCC 和 LLVM 的 RISC-V 后端已较为稳定,并被主流 Linux 发行版收录为官方工具链组件。对于 llama.cpp、Linux 内核模块和 BPF 用户态加载器等包含较多 C/C++ 源文件的项目,交叉编译可以显著缩短构建时间,避免在算力有限的开发板上进行长时间原生编译。本项目采用 x86_64 WSL2 主机构建、RISC-V 板端部署的流程,其优点如下:

  • 技术成熟度:GCC 和 LLVM 均已支持 RISC-V 目标后端,相关工具链可通过主流 Linux 发行版或交叉编译 SDK 获取。
  • 生态支持:RISC-V 国际基金会和多家处理器、操作系统及软件厂商持续完善工具链、运行库和发行版支持。
  • 开发效率:在 x86 主机上构建大型 C/C++ 项目通常比在开发板上原生编译更高效,适合频繁迭代内核模块、BPF 程序和 llama.cpp。
  • 部署可控性:交叉编译可以在主机端固定编译器版本、目标 ABI、-march 参数和依赖路径,减少板端环境差异对构建结果的影响。

image-20260703104231773

​ 因此,本项目将交叉编译作为默认构建方式,用于保证 llama.cpp、内核模块、BPF skeleton 和用户态加载器在同一工具链约束下生成可部署产物。

2.1.5 RISC-V 多核平台上的 LLM 推理优化技术(V-Seek)与 PerfXLM 等 RISC-V 推理工作

V-Seek 研究了在 64 核 Sophon SG2042 RISC-V 平台上运行 llama.cpp 的性能优化,主要包括三个方向:

  1. 为量化 LLM 的关键 GEMV/GEMM 操作设计向量化计算内核;
  2. 比较不同编译工具链及其指令扩展支持和优化能力;
  3. 研究线程绑定、NUMA balancing 和内存交错等线程/内存映射策略。

​ 论文结果表明,优化向量内核能够改善数据局部性和计算吞吐;Clang 19 在其测试中优于 GCC 13.2;关闭不合适的 NUMA balancing 并采用内存交错可以减少线程和内存页迁移。论文在 Llama 7B、DeepSeek R1 Distill Llama 8B 和 Qwen 14B 等模型上报告了吞吐提升。

​ PerfXLM 是较早公开展示的 RISC-V CPU 大模型推理引擎工作,其公开材料介绍了 RISC-V 平台上的 LLM 推理引擎设计、计算内核和性能优化方法。此外,已有研究还从专用向量处理器、低比特量化和软硬件协同等方向提高 RISC-V 上的神经网络或语言模型推理性能。这些工作主要关注计算内核效率提升,包括提高向量单元利用率、减少权重访存、改进量化点积以及设计专用指令或加速器。

2.2 问题量化方案

2.2.1 RVV 与 Linux V 状态管理技术

​ RISC-V Vector Extension(RVV)采用向量长度无关(Vector Length Agnostic,VLA)的编程模型。软件通过 vsetvl/vsetvli 根据硬件实现的向量寄存器长度 VLEN 和本次计算的数据类型设置有效向量长度 vl,同一套向量代码因而可以运行在不同 VLEN 的处理器上。RVV 架构包含 32 个向量寄存器 v0~v31,并包含 vl、vtype、vstart、vcsr 等控制状态。VLA 提高了程序的可移植性,但操作系统在任务切换时必须为每个使用过 RVV 的任务维护与实际 VLEN 对应的寄存器映像。

​ sstatus.VS 是 RISC-V 特权架构中用于描述 Vector 上下文状态的字段。Linux 目标内核使用 Off、Initial、Clean 和 Dirty 四种编码:Off 表示当前上下文不允许直接使用 Vector;Initial 表示 Vector 状态已初始化;Clean 表示内存映像与寄存器状态一致;Dirty 表示任务执行 Vector 指令后,寄存器内容可能已经改变,需要在相应的上下文切换路径上保存。需要强调的是,Dirty 是体系结构提供的状态提示,并不直接给出保存耗时;具体是否调用保存函数以及何时恢复,取决于目标 Linux 内核的实现。

​ 在本项目使用的 6.18.37-current-spacemit 内核中,__switch_to_vector() 调用 riscv_v_vstate_save() 检查切出任务的 VS 状态。仅当状态为 Dirty 时,内核才执行 __riscv_v_vstate_save(),随后将状态标记为 Clean;对于即将运行的任务,内核设置延迟恢复标志,并在返回用户态前执行恢复。该机制避免了对未使用 Vector 或寄存器映像未变化任务的无条件保存,可以视为基于 VS 状态的条件保存与延迟恢复机制。该机制仍无法消除一种情况:若一个持续执行 RVV 指令的可运行线程被调度器切出,其状态通常已经变为 Dirty,此次保存属于保证任务语义正确所必需的操作。因此,本项目不修改 Linux V 状态保存/恢复的正确性逻辑,而是在调度层减少由策略导致的、可避免的 V-hot 线程切出。

​ 对于 SpacemiT K1 的 VLEN=256 实现,仅 32 个向量寄存器组的一次完整传输量为:

SV−reg=32×VLEN8=32×2568=1024 ByteS_{\mathrm{V-reg}}=32\times\frac{\mathrm{VLEN}}{8} =32\times\frac{256}{8}=1024\ \mathrm{Byte}

​ 该式描述向量寄存器组的主要数据量,不包含 vl、vtype、vstart、vcsr 等控制状态的读写,也不等同于缓存或内存总线上的实际流量。真实时间还受 CPU 频率、缓存命中、内存层次、探针开销和同时运行任务影响,因此必须在真机上直接测量,不宜仅根据 1024 Byte 推算延迟。

2.2.2 kprobe、kretprobe、tracepoint 与 eBPF 观测技术

​ Linux 提供多类动态观测机制。调度 tracepoint(如 sched_switch 和 sched_migrate_task)接口相对稳定,适合统计任务切换与迁核行为,但一次 sched_switch 并不必然伴随 V 状态保存。kprobe 可以在允许探测的内核指令位置执行处理函数,kretprobe 在函数进入和返回时分别记录时间,适合测量具体保存函数的调用次数及持续时间;其限制是内联函数可能没有独立全局符号,局部同名符号可能指向不同调用路径,.noinstr.text 等黑名单区域也不允许动态探测。eBPF 配合 libbpf 可以安全地加载受验证的内核观测程序、使用 per-CPU map 低开销汇总数据,并将附着状态输出到用户态,但它同样受目标内核 kprobe 可用性约束。

基于上述差异,本项目采用“内核模块主测量 + eBPF 辅助观测”的两级方案,避免将调度切换次数直接等同于 V 状态保存次数:

  1. vstate_profiler.ko 从与开发板内核完全一致的 vmlinux 中解析调度路径的局部 __riscv_v_vstate_save 目标,使用 kretprobe 统计调用次数与纳秒耗时,并通过 target_tgid 只选择目标 llama-server 进程。
  2. vstate_observer.bpf.o 尝试附着 V 状态 save/restore kprobe,同时始终使用调度 tracepoint 统计目标进程切换和迁核;若函数探针不可用,JSON 结果记录附着失败并退化为调度行为观测。
  3. 两条观测路径使用同一实验时间窗和同一 TGID。保存函数数据回答“实际观测到多少次保存、耗时多少”,调度事件回答“任务为何频繁离开 CPU”,二者与应用层 TTFT/TPS 数据按同一 run_id 对齐。

kprobe/kretprobe 会向被测路径引入固定观测开销,因此绝对纳秒数不应直接解释为无探针条件下的纯硬件开销。实验设计要求基线组与优化组加载相同版本的 profiler、使用相同过滤方式,并以相对变化作为主要证据;若未来需要报告绝对周期,应增加空探针标定或使用内核内建静态计时点进行校正。

2.2.3 量化指标

主要字段及其数学含义如下表所示:

程序字段 数学符号 含义
save_count NsaveN_{\mathrm{save}} 实验窗口内捕获到的 V 状态保存次数
restore_count NrestoreN_{\mathrm{restore}} 实验窗口内捕获到的可探测恢复次数
save_total_ns TsaveT_{\mathrm{save}} 所有已捕获保存事件的总时间
restore_total_ns TrestoreT_{\mathrm{restore}} 所有已捕获恢复事件的总时间
completed_requests NreqN_{\mathrm{req}} 成功完成的推理请求数
elapsed_ns TwallT_{\mathrm{wall}} profiler 实验窗口的墙钟时间
vstate_ns_per_request TV/reqT_{\mathrm{V/req}} 每个成功请求对应的已观测 V 状态时间
vstate_cpu_capacity_ppm RcapacityR_{\mathrm{capacity}} V 状态时间占全部在线 CPU 时间容量的百万分比

设第 ii 次保存耗时为 tisavet^{\mathrm{save}}_i,第 jj 次可探测恢复耗时为 tjrestoret^{\mathrm{restore}}_j,在线 CPU 数为 NCPUN_{\mathrm{CPU}},则:

Tsave=∑i=1Nsavetisave,Trestore=∑j=1NrestoretjrestoreT_{\mathrm{save}}=\sum_{i=1}^{N_{\mathrm{save}}}t^{\mathrm{save}}_i, \qquad T_{\mathrm{restore}}=\sum_{j=1}^{N_{\mathrm{restore}}}t^{\mathrm{restore}}_j

TV,obs=Tsave+Trestore,TV/req=TV,obsNreqT_{\mathrm{V,obs}}=T_{\mathrm{save}}+T_{\mathrm{restore}}, \qquad T_{\mathrm{V/req}}=\frac{T_{\mathrm{V,obs}}}{N_{\mathrm{req}}}

Rcapacity=TV,obsTwall×NCPUR_{\mathrm{capacity}}= \frac{T_{\mathrm{V,obs}}}{T_{\mathrm{wall}}\times N_{\mathrm{CPU}}}

2.3 调度器设计方案

2.3.1 V 状态切换问题的本质

​ RISC-V Vector Extension 为每个 hart 提供 32 个向量寄存器 v0–v31,寄存器宽度由硬件的 VLEN 决定。在本实验使用的 SpacemiT K1 平台上,VLEN=256 bit,因此完整保存一组向量寄存器需要写入:

32 × 256 bit ÷ 8 = 1024 Byte

​ 恢复时还需要读取同等规模的数据,并处理 vl、vtype、vstart、vcsr 等向量控制状态。因此,向量线程切换不仅包含普通通用寄存器上下文,还会产生额外的内存访问和内核执行时间。

​ LLM 推理中的 Prefill 和 Decode 阶段均会持续使用 RVV 指令。线程执行向量指令后,sstatus.VS 会变为 Dirty。若该线程此时被抢占、阻塞或迁移,内核必须保存其向量状态;线程再次运行时,还需要恢复相应状态。单次开销虽然有限,但在高并发推理中会随大量调度切换反复发生,并影响推理吞吐、首 Token 延迟(TTFT)和尾延迟。

2.3.2 与 Linux 公平调度机制的关系

Linux 普通任务由公平调度类管理,传统上通常称为 CFS。现代内核在公平调度类中进一步采用 EEVDF 进行任务选择,其核心目标仍然是根据任务权重、虚拟运行时间和可运行状态公平分配 CPU 时间。

默认调度器不感知某个线程是否持有高代价的 Dirty V 状态,也不了解该线程正在执行 Prefill 还是 Decode。因此,LLM 计算线程与普通 CPU 密集型线程在通用调度目标下缺少区别化处理,可能出现:

  • RVV 计算线程在关键计算过程中被频繁抢占;
  • 同一计算线程在不同 CPU 之间迁移;
  • 多个 Dirty V 线程在同一 CPU 上交替运行;
  • 时间片过短,尚未充分利用已装入的向量状态就发生切换;
  • Prefill 和 Decode 使用相同的调度参数,无法兼顾计算连续性和响应延迟。

公平调度的优化目标是通用公平性,而非最小化扩展寄存器状态的切换成本。因此,高并发 RVV 推理需要在公平调度基础上增加对向量状态和推理阶段的感知。

本项目通过 sched_ext 实现可扩展调度策略,不直接修改 Linux 公平调度器代码。调度器退出或加载失败时,任务可以回到 Linux 默认公平调度类,从而降低实验性调度策略对系统稳定性的影响。

2.3.3 与 Linux lazy save/restore 的关系

​ Linux 利用 RISC-V VS 状态位管理向量上下文。对于未使用向量指令,或向量状态未发生变化的线程,内核可以避免无意义的完整保存,从而降低普通负载下的上下文切换成本,这类机制可概括为 lazy save/restore。

​ 然而,lazy 管理只能判断“本次是否有必要保存”,无法解决保存触发频率高的根因。LLM 计算线程通常长时间连续执行 RVV 指令,其 V 状态通常处于 Dirty。在这种负载下,关键路径上的抢占仍可能触发完整保存,lazy 机制能够省略的操作有限。因此,本项目不替代或修改 Linux 的 lazy save/restore,而是从调度层减少触发保存的次数:

Linux lazy save/restore:
避免保存未使用或未修改的向量状态

调度优化:
减少 Dirty V 线程被抢占、迁移和交替调度的次数

​ 两者处于不同层次并能够协同工作:lazy 机制负责保证上下文管理的正确性并避免无效保存,V 状态感知调度器负责降低必要保存发生的频率。

2.3.4 V-hot 状态感知调度

调度器通过任务的 VS=Dirty 状态判断其是否真实使用过 RVV,并记录最近一次发现 Dirty 状态的时间。若任务在设定的衰减窗口内仍具有向量活动,则将其识别为 V-hot 任务。

对于 V-hot 任务,调度器采用以下策略:

  • 分配更适合连续向量计算的时间片;
  • 减少计算过程中的不必要抢占;
  • 优先从专用或本地 DSQ 中选择向量任务;
  • 优先保持任务在最近运行的 CPU 上执行;
  • 当目标 CPU 繁忙时允许自适应回退,避免强粘性造成排队;
  • 对非向量线程继续采用普通调度参数,避免扩大优化影响范围。

V-hot 判断来自内核能够观察到的真实任务状态,不依赖用户态声明。因此,即使 llama.cpp 未提供推理阶段信息,调度器仍能执行基本的向量感知优化。

2.3.5 用户态—内核态阶段协同

仅依靠 V-hot 可以判断线程近期是否使用过向量寄存器,但无法区分其正在执行 Prefill 还是 Decode。为此,项目通过固定在 bpffs 中的 eBPF Map 建立用户态—内核态提示接口。

llama.cpp 在计算 batch 开始前写入:

  • 当前阶段:PREFILL、DECODE、MIXED 或 IDLE;
  • Prefill Token 数量;
  • Decode Token 数量;
  • batch 总 Token 数;
  • 提示更新时间等信息。

调度器将阶段提示与 V-hot 检测结合,不直接信任用户态提示。只有近期被检测为 Dirty 的向量任务才应用阶段策略,避免 HTTP、日志和控制线程因属于同一个 llama-server 进程而被错误提升。

不同阶段采用不同的调度倾向:

  • PREFILL:矩阵计算规模大、向量计算连续,采用较长时间片和较高计算权重, 减少中途抢占;
  • DECODE:逐 Token 生成、延迟敏感,采用相对较短的时间片,使请求更快获得 下一次运行机会;
  • MIXED:按照 Prefill 与 Decode Token 比例加权计算时间片和权重,避免 粗粒度套用单一阶段参数;
  • IDLE:取消阶段优化,避免过期提示影响后续调度。

提示超过 TTL、字段校验失败或 Map 不可用时,调度器自动退回 V-hot 策略;sched_ext 调度器退出后,任务则回到 Linux 默认公平调度类。由此形成“主动提示—V-hot 回退—默认调度回退”的多级保护机制。

2.3.6 整体优化逻辑

Linux 默认调度下的问题路径可以概括为:

Linux 默认公平调度
        ↓
未感知 RVV 状态,Dirty V 线程可能频繁抢占或迁移
        ↓
触发 Linux V 状态 save/restore 路径
        ↓
产生额外的上下文切换开销

本项目的优化路径为:

检测真实 VS=Dirty 状态
        ↓
识别近期持续使用 RVV 的 V-hot 线程
        ↓
结合 llama.cpp 提供的 Prefill/Decode/Mixed 阶段提示
        ↓
动态调整时间片、调度权重、DSQ 和 CPU 粘性
        ↓
减少 Dirty V 线程的抢占、迁移与交替运行
        ↓
降低 V 状态保存次数和保存耗时
        ↓
改善推理吞吐、TTFT 与尾延迟

综上,本方案保留 Linux lazy save/restore 的正确性保障,以 sched_ext 补充默认公平调度对高代价向量状态缺乏感知的问题,再通过 eBPF Map 引入 llama.cpp 的推理阶段语义,形成内核状态观测与用户态负载提示相结合的 V 状态感知调度机制。

3、项目实现

3.1 系统总体架构

image-20260729102823175

​ 系统由交叉编译环境的内核(kernel_build)、V 状态观测内核模块(os_module)、llama框架(llama.cpp)、模型板端调用推理服务和实验分析(rvv_llm_lab)、 v-hot调度器(sched_ext)、动态PD阶段提示调度器(dynamic_sched_ext)、和 x86 AVX2 横向对照(x86_avx2)七部分组成。各部分由 run_exp.sh 使用统一的 run_id 串联,降低人工转录造成的数据错配风险;x86 对照实验复用同一套 loadgen 口径,补充 XSAVE/xstate 与调度切换数据,用于分析 RVV VLA 状态管理的特殊性。

flowchart LR
    A[Windows x86_64 / WSL2
RISC-V 交叉编译] -->|"llama-server / llama-cli"| B[BananaPi BPI-F3
llama-server] A -->|"vstate_profiler.ko"| D["内核 V 状态探针"] A -->|"vstate_observer"| G["libbpf observer"] A -->|"调度器二进制"| S["sched_ext 调度器产物"] A -->|"x86-avx2 llama-server + 对照脚本"| H["x86_avx2
AVX2 / XSAVE 实验模块"] R["rvv_llm_lab/run_exp.sh
统一 run_id 编排"] --> M0 R --> M1 R --> M2 subgraph M["调度模式(三选一,每轮只启用一种)"] direction TB M0["Baseline
CFS 默认调度"] M1["scx_vstate
V-hot 长时间片 / vtime / 原 CPU 直派"] M2["dynamic_sched_ext
phase_hints / Prefill-Decode 权重 / sticky-adaptive"] end S --> M1 S --> M2 M0 -->|"本轮调度环境"| B M1 -->|"本轮调度环境"| B M2 -->|"本轮调度环境"| B B -.->|"仅 dynamic 模式写入 phase_hints"| M2 E["异步 loadgen
2/4/8 并发"] -->|"/v1/completions"| B B -->|"TGID、sched_switch、迁核"| D B -->|"sched_switch、迁核辅助观测"| G D -->|"debugfs stats/csv"| F["结果合并与统计分析"] G -->|"observer.json"| F B -->|"流式 token、TTFT、latency"| F H -->|"LLM 指标、上下文切换、xstate 字节"| F

代码按职责划分为:

1、os_module/ 测量目标内核保存路径;

2、sched_ext/vstate/ 实现静态 V-hot 感知调度策略和加载器;

3、dynamic_sched_ext/ 实现带 Prefill/Decode 阶段提示的动态调度器;

4、rvv_llm_lab/ 实现 llama.cpp 构建、eBPF 辅助观测、并发压测、结果合并及绘图;

5、x86_avx2/ 实现 x86 AVX2 版本 llama.cpp 对照实验、XSAVE 状态探测和批量结果汇总。

​ 该分层设计使 profiler、静态调度器和动态调度器可以独立启停,能够组织 baseline、profiler、scx_vstate、dynamic_sched_ext 和 scx_profiler 等实验组合,并支持对 V-hot 判断、阶段提示、时间片和 CPU 局部性策略进行消融测试。

3.2 V 状态开销量化模块实现

1、总体框架

V 状态开销量化模块的目标是将“调度过程中是否实际保存 RVV 寄存器”这一内核行为转换为可统计指标。普通 sched_switch 仅能表明线程发生了切换,无法判断该线程是否持有 Dirty V 状态;因此本模块将 kretprobe 挂载至 __riscv_v_vstate_save() 和对应 restore 热路径,在函数入口记录时间戳,在返回时计算一次保存或恢复的纳秒级耗时,并按目标 llama-server 的 TGID 过滤样本。

该模块以 debugfs 暴露 control、stats 和 csv 三个接口。实验开始时由脚本写入 control: start 清空旧数据并打开采样窗口,压测结束后写入 control: stop 固定时间范围,再读取 stats/csv 与 loadgen 输出合并。该流程确保 V 状态保存次数、保存耗时、TTFT、TPS 和请求数来自同一轮实验窗口,避免不同时间段手工观测造成数据错配。

用户写入 control: start
    │
    ├─ reset_stats() 清空旧统计
    ├─ 记录 run_start_ns
    └─ enabled=true
              │
              ▼
目标 llama-server 线程使用 RVV
              │
              ▼
Linux 调度器准备切出该线程
              │
              ├─ __switch_to kprobe
              │      └─ switch_pre_handler()
              │          ├─ switches_out++
              │          └─ 分类 runnable/blocking
              │
              ▼
内核调用 __riscv_v_vstate_save()
              │
              ├─ 入口:probe_entry()
              │      ├─ 检查 enabled
              │      ├─ 检查 target_tgid
              │      └─ 保存 start_ns
              │
              ├─ 内核保存 v0~v31 及相关 V 状态
              │
              └─ 返回:save_return()
                     ├─ 读取结束时间
                     ├─ 计算 now-start_ns
                     └─ metric_add()
                         ├─ save_count++
                         ├─ save_total_ns += latency
                         └─ 更新 min/max/histogram
              │
              ▼
用户写入 control: stop
              │
              ├─ enabled=false
              ├─ 同步所有 CPU
              └─ 记录 run_stop_ns
              │
              ▼
用户读取 stats/csv
              │
              ├─ collect_totals()
              │   └─ 合并所有 per-CPU 数据
              ├─ show_metric()
              └─ 输出次数、平均时间、总时间和每请求开销

2、地址解析函数

SpacemiT 6.18 内核中的底层保存函数可能以内联或局部符号形式存在,不宜仅依赖公开导出的符号名。初始化阶段首先通过与目标内核一致的 vmlinux 和预先确认的 anchor/delta 解析出 save/restore 实际地址,随后将 kretprobe 注册到该地址。模块同时检查 CONFIG_RISCV_ISA_V,避免在未启用 RVV 的内核上产生误导性数据。

初始化成功后,模块创建 debugfs 目录并打印解析到的保存地址、恢复地址和 VLEN。报告中的 vlen_bits=256 来源于该处读取的 riscv_v_vsize,后续用于计算每次保存对应的理论 V 寄存器状态字节数。

static int __init vstate_profiler_init(void)
{
    struct dentry *entry;
    unsigned long save_addr;
    unsigned long restore_addr;
    int ret;

    if (!IS_ENABLED(CONFIG_RISCV_ISA_V))
        return -EOPNOTSUPP;

    ret = resolve_local_target(VSTATE_SAVE_ANCHOR, VSTATE_SAVE_DELTA,
                   &save_addr);
    if (ret) {
        pr_err(PROF_NAME ": cannot resolve save anchor: %d\n", ret);
        return ret;
    }
    ret = resolve_local_target(VSTATE_RESTORE_ANCHOR, VSTATE_RESTORE_DELTA,
                   &restore_addr);
    if (ret) {
        pr_err(PROF_NAME ": cannot resolve restore anchor: %d\n", ret);
        return ret;
    }

    save_probe.kp.addr = (kprobe_opcode_t *)save_addr;
    save_probe.entry_handler = probe_entry;
    save_probe.handler = save_return;
    save_probe.data_size = sizeof(struct probe_sample);
    save_probe.maxactive = max_t(int, 32, num_possible_cpus() * 4);

    restore_probe.kp.addr = (kprobe_opcode_t *)restore_addr;
    restore_probe.entry_handler = probe_entry;
    restore_probe.handler = restore_return;
    restore_probe.data_size = sizeof(struct probe_sample);
    restore_probe.maxactive = max_t(int, 32, num_possible_cpus() * 4);

    ret = register_kretprobe(&save_probe);
    if (ret) {
        pr_err(PROF_NAME ": cannot probe V save at %px: %d\n",
               (void *)save_addr, ret);
        return ret;
    }
    ret = register_kretprobe(&restore_probe);
    if (ret) {
        pr_err(PROF_NAME ": cannot probe V restore at %px: %d\n",
               (void *)restore_addr, ret);
        goto err_restore;
    }

    ret = register_switch_kprobe();
    if (ret) {
        /* Save/restore latency measurement is the primary function. */
        pr_warn(PROF_NAME ": __switch_to probe unavailable (%d); "
            "switch classification disabled\n", ret);
        ret = 0;
    }

    debugfs_root = debugfs_create_dir(PROF_NAME, NULL);
    if (IS_ERR_OR_NULL(debugfs_root)) {
        ret = debugfs_root ? PTR_ERR(debugfs_root) : -ENOMEM;
        debugfs_root = NULL;
        goto err_debugfs;
    }
    entry = debugfs_create_file("stats", 0444, debugfs_root, NULL,
                    &stats_fops);
    if (IS_ERR_OR_NULL(entry)) {
        ret = entry ? PTR_ERR(entry) : -ENOMEM;
        goto err_debugfs;
    }
    entry = debugfs_create_file("csv", 0444, debugfs_root, NULL, &csv_fops);
    if (IS_ERR_OR_NULL(entry)) {
        ret = entry ? PTR_ERR(entry) : -ENOMEM;
        goto err_debugfs;
    }
    entry = debugfs_create_file("control", 0200, debugfs_root, NULL,
                    &control_fops);
    if (IS_ERR_OR_NULL(entry)) {
        ret = entry ? PTR_ERR(entry) : -ENOMEM;
        goto err_debugfs;
    }

    pr_info(PROF_NAME ": loaded, save=%px restore=%px VLEN=%lu bits\n",
        (void *)save_addr, (void *)restore_addr,
        riscv_v_vsize / 32 * 8);
    if (switch_probe_active)
        pr_info(PROF_NAME ": __switch_to probe active at +%u\n",
            switch_probe_offset);
    return 0;

err_debugfs:
    debugfs_remove_recursive(debugfs_root);
    if (switch_probe_active)
        unregister_kprobe(&switch_probe);
    unregister_kretprobe(&restore_probe);
err_restore:
    unregister_kretprobe(&save_probe);
    return ret;
}

3、入口探针函数

入口探针仅执行轻量过滤和时间戳记录。enabled 保证探针只在正式实验窗口内计数,task_selected(current) 用目标 TGID 排除系统后台线程,start_ns 保存在 kretprobe instance 的私有数据中。若当前任务不属于目标进程,函数返回 1 并跳过本次返回探针,从而降低观测开销并避免污染统计。

static int probe_entry(struct kretprobe_instance *ri, struct pt_regs *regs)
{
    struct probe_sample *sample;

    if (!READ_ONCE(enabled) || !task_selected(current))
        return 1;

    sample = (struct probe_sample *)ri->data;
    sample->start_ns = ktime_get_mono_fast_ns();
    return 0;
}
NOKPROBE_SYMBOL(probe_entry);

4、出口探针函数

出口探针读取当前单调时钟,与入口保存的 start_ns 相减得到本次 V 状态保存耗时。统计写入 this_cpu_ptr(&cpu_stats) 指向的 per-CPU 结构,避免多 CPU 并发更新时发生全局锁竞争。恢复路径采用同样的入口/出口结构;若目标内核的恢复热路径不适合探测,分析脚本将结果标记为 module_save_only_lower_bound,表示当前 V 状态时间为保存侧下界。

static int save_return(struct kretprobe_instance *ri, struct pt_regs *regs)
{
    struct probe_sample *sample = (struct probe_sample *)ri->data;
    struct vstate_cpu_stats *stats = this_cpu_ptr(&cpu_stats);
    u64 now = ktime_get_mono_fast_ns();

    metric_add(&stats->save, now - sample->start_ns);
    return 0;
}
NOKPROBE_SYMBOL(save_return);

5、V 状态保存次数累加

metric_add() 将单次延迟同时累加到总次数、总耗时、最小/最大耗时和直方图中。直方图桶使用 fls64(ns) 按 2 的幂分布,用于观察保存路径是否存在偶发长尾;均值由 total_ns / count 得到,用于报告 save_avg_ns。这些统计保持在 per-CPU 数据中,读取 stats/csv 时再统一合并。

static __always_inline void metric_add(struct latency_metric *metric, u64 ns)
{
    unsigned int bucket = ns ? fls64(ns) : 0;

    if (bucket >= HIST_BUCKETS)
        bucket = HIST_BUCKETS - 1;
    if (!metric->count || ns < metric->min_ns)
        metric->min_ns = ns;
    if (ns > metric->max_ns)
        metric->max_ns = ns;
    metric->count++;
    metric->total_ns += ns;
    metric->hist[bucket]++;
}
NOKPROBE_SYMBOL(metric_add);

6、采样窗口与 CPU 时间容量计算

elapsed_ns() 描述本轮采样窗口持续时间;若读取发生在实验停止前,则用当前时间临时计算已运行时长。cpu_capacity_ratio() 将 V 状态保存/恢复总耗时除以“实验窗口时间 × 在线 CPU 数”,得到 vstate_cpu_capacity_ppm。相比单独报告总毫秒数,该指标更适合比较不同并发和不同运行时长的实验,因为它表示 V 状态管理消耗了整机 CPU 时间容量中的百万分之几。

static u64 elapsed_ns(void)
{
    u64 start = READ_ONCE(run_start_ns);
    u64 stop = READ_ONCE(run_stop_ns);

    if (!start)
        return 0;
    if (!stop)
        stop = ktime_get_mono_fast_ns();
    return stop > start ? stop - start : 0;
}

static u64 cpu_capacity_ratio(u64 vector_ns, u64 elapsed, u64 scale)
{
    u64 capacity_ns;

    if (!elapsed)
        return 0;
    capacity_ns = elapsed * num_online_cpus();
    return mul_u64_u64_div_u64(vector_ns, scale, capacity_ns);
}

3.3 scx_vstate 调度器模块实现

1、总体框架

f41a30ef3f29c87bc96e4f834714487c

sched_ext/vstate/ 中的 scx_vstate 是一个基于 Linux sched_ext 的 RISC-V V 状态感知调度器。它以内核在任务停止运行时读取到的 RISC-V VS=Dirty 作为向量热点信号:当任务切出时仍为 Dirty,表明该线程刚使用过 RVV,后续再次被短时间片打断时更可能产生 V 状态保存压力。调度器据此将任务标记为短期 V-hot,并在后续 CPU 选择、时间片和虚拟运行时间计费中给予有限倾斜。

该调度器的基本流程是:任务被唤醒时首先执行 select_cpu(),优先将 V-hot 任务放回上次运行的空闲 CPU;若无法本地直派,则在 enqueue() 中按虚拟运行时间插入共享 VSTATE_DSQ;CPU 本地队列为空时由 dispatch() 从共享队列取出任务;任务开始运行时由 running() 记录运行 CPU 和起始时间;任务停止运行时由 stopping() 读取 VS 状态、更新 V-hot 历史并完成 vtime 计费。该过程仅改变任务选择、时间片和公平计费,不修改 Linux 原有的 V 状态保存/恢复语义。

2、V-hot 任务判定

V-hot 判断由目标任务声明和 Dirty 历史共同完成。loader 将目标 TGID 写入 BPF map,使刚被接管的推理线程在初始阶段进入调度器管理;任务进入实际运行后,stopping() 中观测到的 VS=Dirty 更新该任务的 last_dirty_ns。当当前时间与 last_dirty_ns 的差值小于 hot_decay_ns 时,任务被视为 V-hot。默认 hot_decay_ns 约为 200 ms,用于覆盖 llama.cpp worker 在同步、等待 batch 和再次唤醒之间的短暂停顿,避免刚使用过 RVV 的线程立刻被当作普通任务轮转。

static bool task_is_vhot(struct task_struct *p, struct vstate_task_ctx *ctx,
             u64 now)
{
    if (task_is_declared(p))
        return true;

    return ctx && ctx->last_dirty_ns &&
           now - ctx->last_dirty_ns < hot_decay_ns;
}

3、CPU 选择与本地 DSQ 直派

select_cpu() 是任务被唤醒时的第一道放置决策。对于 V-hot 任务,调度器优先检查上次运行 CPU 是否仍在任务亲和性集合内且处于 idle 状态;若满足条件,则将任务插入该 CPU 的本地 DSQ,使线程继续在原 CPU 上运行。该策略主要降低迁核带来的 cache/TLB 扰动,并减少 V-hot 线程在共享队列中被其他任务反复插队的概率。若原 CPU 不空闲,则回退到 sched_ext 默认选核逻辑,以避免过度粘性导致并行 worker 在同一 CPU 上排队。

s32 BPF_STRUCT_OPS(vstate_select_cpu, struct task_struct *p, s32 prev_cpu,
               u64 wake_flags)
{
    struct vstate_task_ctx *taskc = lookup_task_ctx(p);
    bool is_idle = false;
    s32 cpu;

    /* Preserve V-register/cache locality when the previous CPU is idle. */
    if (taskc && task_is_vhot(p, taskc, bpf_ktime_get_ns()) &&
        taskc->last_cpu >= 0 &&
        bpf_cpumask_test_cpu(taskc->last_cpu, p->cpus_ptr) &&
        scx_bpf_test_and_clear_cpu_idle(taskc->last_cpu)) {
        scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, vector_slice_ns, 0);
        stat_inc(VSTAT_LOCAL_IDLE);
        return taskc->last_cpu;
    }

    cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
    if (is_idle) {
        u64 slice = task_is_vhot(p, taskc, bpf_ktime_get_ns()) ?
                vector_slice_ns : normal_slice_ns;

        scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, slice, 0);
        stat_inc(VSTAT_LOCAL_IDLE);
    }

    return cpu;
}

4、共享 DSQ 入队与 vtime 排序

未在 select_cpu() 中插入本地 DSQ 的任务由 enqueue() 处理。入队流程包括三项操作:根据 V-hot 状态选择时间片,读取 p->scx.dsq_vtime 作为排序键,并将任务插入共享 VSTATE_DSQ 等待后续分发。

enqueue() 负责将未能本地直派的任务放入共享 VSTATE_DSQ。V-hot 任务使用较长的 vector_slice_ns,普通任务使用 normal_slice_ns;二者仍按照 p->scx.dsq_vtime 排序,并非将 V-hot 任务固定放置在队首。代码中的 sleep credit 限幅用于避免长时间睡眠任务醒来后携带过多虚拟时间优势,降低饥饿风险。

void BPF_STRUCT_OPS(vstate_enqueue, struct task_struct *p, u64 enq_flags)
{
    struct vstate_task_ctx *taskc = lookup_task_ctx(p);
    u64 now = bpf_ktime_get_ns();
    u64 slice = task_is_vhot(p, taskc, now) ? vector_slice_ns : normal_slice_ns;
    u64 vtime = p->scx.dsq_vtime;
    u64 max_credit = vector_slice_ns > normal_slice_ns ?
             vector_slice_ns : normal_slice_ns;

    stat_inc(VSTAT_ENQUEUED);

    /* Bound sleep credit so an idle task cannot jump arbitrarily far ahead. */
    if ((s64)(vtime - (vtime_now - max_credit)) < 0)
        vtime = vtime_now - max_credit;

    scx_bpf_dsq_insert_vtime(p, VSTATE_DSQ, slice, vtime, enq_flags);
}

5、共享 DSQ 分发

当某个 CPU 的本地 DSQ 没有可运行任务时,sched_ext 调用 dispatch(),从共享 VSTATE_DSQ 取出排序最靠前的任务,并移动到当前 CPU 的本地 DSQ。该回调的实现保持简洁:策略复杂度主要集中在 V-hot 标记、时间片长度和 vtime 计费上,分发阶段仅执行队列搬运,以减少 BPF 调度器热路径中的额外判断。

void BPF_STRUCT_OPS(vstate_dispatch, s32 cpu, struct task_struct *prev)
{
    if (scx_bpf_dsq_move_to_local(VSTATE_DSQ))
        stat_inc(VSTAT_DISPATCHED);
}

6、运行状态记录

running() 在任务实际开始占用 CPU 时更新全局 vtime_now、任务上次运行时间和上次 CPU。若本次 CPU 与记录中的 last_cpu 不同,则累加迁核计数;若该任务仍处于 V-hot 窗口,则累加 V-hot 运行次数。后续分析可将这些调度器内部计数与 profiler 的保存次数放在同一 run ID 下联合分析,用于评估调度过程是否减少迁核和 Dirty 切出。

void BPF_STRUCT_OPS(vstate_running, struct task_struct *p)
{
    struct vstate_task_ctx *taskc = lookup_task_ctx(p);
    s32 cpu = bpf_get_smp_processor_id();
    u64 now = bpf_ktime_get_ns();

    if ((s64)(vtime_now - p->scx.dsq_vtime) < 0)
        vtime_now = p->scx.dsq_vtime;

    if (!taskc)
        return;

    taskc->last_run_at_ns = now;
    if (taskc->last_cpu >= 0 && taskc->last_cpu != cpu)
        stat_inc(VSTAT_MIGRATIONS);
    taskc->last_cpu = cpu;

    if (task_is_vhot(p, taskc, now))
        stat_inc(VSTAT_VECTOR_RUNS);
}

7、停止运行与 vtime 计费

stopping() 是调度器获得真实 V 状态反馈的关键回调。任务即将离开 CPU 时,RISC-V 架构状态仍可从 task_pt_regs() 中读取;若 VS=Dirty,调度器记录 last_dirty_ns,并根据 runnable 区分可运行抢占和主动阻塞。前者更接近调度策略可优化的保存压力,后者多来自任务主动等待 I/O、锁或同步屏障。

同一回调同时完成 vtime 计费。任务本轮实际运行时间 used 按权重折算为 charge,V-hot 任务再除以有限的 vector_boost,使其在共享队列中获得一定连续运行倾向。由于 boost 仅影响本次计费,且队列仍按 vtime 排序,普通任务仍可获得 CPU 时间,调度器在优化 RVV 保存压力和维持公平性之间保持平衡。

void BPF_STRUCT_OPS(vstate_stopping, struct task_struct *p, bool runnable)
{
    struct vstate_task_ctx *taskc = lookup_task_ctx(p);
    u64 used, charge, weight, boost = 1;
    u64 now = bpf_ktime_get_ns();
    bool dirty;

    if (!taskc)
        return;

    dirty = task_vstate_dirty(p);
    if (dirty) {
        taskc->last_dirty_ns = now;
        stat_inc(VSTAT_DIRTY_STOPS);
        stat_inc(runnable ? VSTAT_DIRTY_PREEMPTS : VSTAT_DIRTY_BLOCKS);
    }

    used = taskc->last_run_at_ns && now > taskc->last_run_at_ns ?
           now - taskc->last_run_at_ns : 0;
    taskc->last_run_at_ns = 0;
    weight = p->scx.weight ?: 100;
    charge = used * 100 / weight;

    /* A bounded preference for V-hot work; vtime still prevents starvation. */
    if (task_is_vhot(p, taskc, now))
        boost = vector_boost ?: 1;
    p->scx.dsq_vtime += charge / boost;
}

3.4 可选完成项c.基于用户态-内核态接口的主动提示调度器模块实现

dynamic_sched_ext/ 实现面向 llama.cpp server 推理负载的动态 Prefill/Decode(PD)分离调度器。该模块由四类代码共同构成:scx_vstate.h 定义 llama-server 与 BPF 调度器共享的阶段提示 ABI;llama.cpp 的 tools/server/server-context.cpp 和 server-vstate.cpp 在 llama_decode() 执行窗口内发布当前 batch 的阶段信息;scx_vstate.c 负责加载 sched_ext struct_ops、pin BPF map、写入调度参数并在 partial 模式下接管目标线程;scx_vstate.bpf.c 在内核调度回调中读取 V 状态和阶段提示,完成 CPU 选择、DSQ 入队、运行统计与 vtime 计费。该调度器保持 Linux 原有 RVV 上下文保存/恢复语义,仅在 sched_ext 允许的调度决策点上,根据真实推理阶段和 Dirty V 历史调整运行窗口与公平计费。

1、总体框架

dynamic

llama-server batch 构造
    ├─ prompt token 标记 prefill
    ├─ sampled / draft token 标记 decode
    └─ llama_decode() 外层 RAII scope 写 phase_hints
            │
            ▼
/sys/fs/bpf/scx_vstate/phase_hints
    key = TGID
    value = phase + prefill_tokens + decode_tokens + updated_ns
            │
            ▼
dynamic_sched_ext/scx_vstate.bpf.c
    ├─ task_phase(): 校验并读取阶段提示
    ├─ stopping(): 读取 pt_regs.status.VS,确认真实 Dirty V 状态
    ├─ task_slice()/task_boost(): 按 P/D/Mixed 计算时间片和 vtime 权重
    ├─ select_cpu()/enqueue()/dispatch(): CPU 放置与 DSQ 分发
    └─ running()/stopping(): 统计阶段运行时间、迁核、Dirty 抢占/阻塞

动态 PD 分离的阶段来源为 llama.cpp 服务端的 batch 构造路径。Prefill 阶段对应 prompt token 被加入 batch 的过程,Decode 阶段对应采样 token 或 speculative draft token 被加入 batch 的过程;当同一个 llama_batch view 同时包含两类 token 时,用户态写入 MIXED,并同时携带 prefill/decode token 计数。BPF 调度器在后续时间片和 vtime 权重计算中使用这些计数,从而使 Mixed 阶段的调度参数与当前 batch 组成保持一致。

2、共享 ABI 与 BPF map

调度器和 llama-server 通过 dynamic_sched_ext/scx_vstate.h 共享 ABI。头文件固定了 phase_hints map 的 pin 路径、ABI 版本、阶段枚举和 value 布局,并使用 _Static_assert 约束结构体大小,避免用户态与 BPF 侧因结构布局变化产生不兼容:

#define VSTATE_PHASE_HINT_ABI_VERSION 2
#define VSTATE_PHASE_MAP_PATH "/sys/fs/bpf/scx_vstate/phase_hints"

enum vstate_phase {
    VSTATE_PHASE_NONE,
    VSTATE_PHASE_IDLE,
    VSTATE_PHASE_PREFILL,
    VSTATE_PHASE_DECODE,
    VSTATE_PHASE_MIXED,
    VSTATE_PHASE_NR,
};

struct vstate_phase_hint {
    unsigned int abi_version;
    unsigned int phase;
    unsigned long long updated_ns;
    unsigned int prefill_tokens;
    unsigned int decode_tokens;
    unsigned int batch_tokens;
    unsigned int reserved;
};
_Static_assert(sizeof(struct vstate_phase_hint) == 32, "phase hint ABI changed");

BPF 程序定义了调度器运行所需的 map:task_ctx 保存任务级 Dirty 历史、上次运行 CPU 和运行起点;target_tgids 保存 loader 声明的目标进程;phase_hints 接收 llama-server 写入的阶段提示;workers 用于 GGML worker 稳定放置实验;stats 采用 per-CPU array 统计调度事件。调度热路径主要依赖 task_ctx、phase_hints、workers 和 stats;partial 模式下的线程接管由 loader 调用 sched_setscheduler(SCHED_EXT) 完成。

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 64);
    __type(key, u32);
    __type(value, struct vstate_phase_hint);
} phase_hints SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, VSTAT_NR);
    __type(key, u32);
    __type(value, u64);
} stats SEC(".maps");

loader 在加载 BPF 程序后将 phase_hints pin 到固定路径。llama-server 随后通过 bpf(BPF_OBJ_GET) 获取 map fd,并以 TGID 为 key 更新阶段提示:

static int pin_phase_map(struct scx_vstate *skel)
{
    if (mkdir(VSTATE_PHASE_MAP_DIR, 0755) && errno != EEXIST)
        return -1;
    if (bpf_map__pin(skel->maps.phase_hints, VSTATE_PHASE_MAP_PATH))
        return -1;
    return 0;
}

3、llama-server 阶段提示生成路径

llama.cpp 的 tools/server/CMakeLists.txt 将 server-vstate.cpp 和 server-vstate.h 加入 server-context 静态库,使阶段提示逻辑随 llama-server 一同编译:

add_library(${TARGET} STATIC
    server-context.cpp
    server-context.h
    server-vstate.cpp
    server-vstate.h
)

在 server_batch 中,add() 接口增加 server_vstate_phase phase 参数,batch 内部为每个 token 保存阶段属性。get_vstate_workload() 直接遍历即将传入 llama_decode() 的 token view,统计其中 prefill 与 decode token 数,并据此生成 prefill、decode 或 mixed 阶段:

bool add(int32_t id_slot, llama_token token, llama_pos pos,
         bool output, server_vstate_phase phase) {
    tokens.push_back({ id_slot, token, pos, output, phase });
    return true;
}

server_vstate_workload get_vstate_workload(int32_t off, int32_t n_tokens) const {
    server_vstate_workload workload;
    for (int32_t i = off; i < off + n_tokens; ++i) {
        workload.prefill_tokens += tokens[i].phase == server_vstate_phase::prefill;
        workload.decode_tokens  += tokens[i].phase == server_vstate_phase::decode;
    }
    if (workload.prefill_tokens && workload.decode_tokens)
        workload.phase = server_vstate_phase::mixed;
    else if (workload.prefill_tokens)
        workload.phase = server_vstate_phase::prefill;
    else
        workload.phase = server_vstate_phase::decode;
    return workload;
}

Prefill token 在 prompt batch 填充处写入:

add_ok &= batch.add(slot.id,
    cur_tok,
    slot.prompt.tokens.pos_next(),
    slot.need_embd(),
    server_vstate_phase::prefill);

Decode token 在采样 token 和 speculative draft token 入 batch 时写入:

add_ok &= batch.add(id, sampled, prompt.tokens.pos_next(),
                    true, server_vstate_phase::decode);
for (auto token : spec_draft) {
    add_ok &= batch.add(this->id, token, pos0++,
                        true, server_vstate_phase::decode);
}

llama_decode() 的阶段提示生命周期由 RAII scope 管理。scope 构造时写入当前 batch 阶段,析构时写回 IDLE,从而将提示有效期限制在本次 decode 调用窗口内:

int ret;
{
    server_vstate_phase_scope phase_scope(
        batch.get_vstate_workload(off, batch_view.n_tokens));
    ret = llama_decode(ctx_tgt, batch_view);
}

server-vstate.cpp 中的 phase_map_client::set() 通过 Linux bpf() syscall 更新 map。该接口默认关闭,仅在 LLAMA_VSTATE_PHASE_HINTS=1 时启用;map key 使用 getpid(),即 llama-server 进程 TGID:

const uint32_t key = uint32_t(getpid());
const phase_hint value = {
    phase_hint_abi_version,
    static_cast<uint32_t>(phase),
    now,
    prefill_tokens,
    decode_tokens,
    prefill_tokens + decode_tokens,
    0,
};
syscall(__NR_bpf, BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr));

4、BPF 侧阶段提示校验与 V-hot 判定

task_phase() 从 phase_hints map 按 TGID 查找提示,并逐项校验 ABI 版本、阶段范围、时间戳、TTL 和 token 数一致性。若任一条件不满足,函数返回 VSTATE_PHASE_NONE,该提示不进入后续调度参数计算:

hint = bpf_map_lookup_elem(&phase_hints, &tgid);
if (!hint)
    return VSTATE_PHASE_NONE;

if (hint->abi_version != VSTATE_PHASE_HINT_ABI_VERSION ||
    hint->phase <= VSTATE_PHASE_NONE || hint->phase >= VSTATE_PHASE_NR ||
    !hint->updated_ns || hint->updated_ns > now ||
    now - hint->updated_ns > phase_hint_ttl_ns ||
    (u64)hint->prefill_tokens + hint->decode_tokens != hint->batch_tokens)
    return VSTATE_PHASE_NONE;

if ((hint->phase == VSTATE_PHASE_PREFILL &&
     (!hint->prefill_tokens || hint->decode_tokens)) ||
    (hint->phase == VSTATE_PHASE_DECODE &&
     (hint->prefill_tokens || !hint->decode_tokens)) ||
    (hint->phase == VSTATE_PHASE_MIXED &&
     (!hint->prefill_tokens || !hint->decode_tokens)))
    return VSTATE_PHASE_NONE;

阶段提示本身不构成 V-hot 判据。task_is_vhot() 要求任务已经具备近期 Dirty 历史;阶段提示仅用于在 V-hot 任务内部进一步区分 Prefill、Decode 或 Mixed 阶段。因此,同一 TGID 下的 HTTP 线程、控制线程等非向量计算线程不会因进程级阶段提示而被视为 V-hot:

static bool task_is_vhot(struct vstate_task_ctx *ctx, u64 now,
             bool hint_present)
{
    bool dirty_hot = ctx && ctx->last_dirty_ns &&
             now - ctx->last_dirty_ns < hot_decay_ns;

    if (hint_present)
        return dirty_hot;

    return dirty_hot;
}

Dirty 信号来自 stopping() 阶段对 pt_regs.status 的读取。该读取点位于任务停止运行的 sched_ext 回调中,仍可通过 bpf_task_pt_regs() 获取有效的用户态架构状态;调度器据此判断 RISC-V VS 字段是否为 Dirty:

static bool task_vstate_dirty(struct task_struct *p)
{
    struct pt_regs *regs;
    unsigned long status;

    regs = (struct pt_regs *)bpf_task_pt_regs(p);
    if (!regs)
        return false;

    status = BPF_CORE_READ(regs, status);
    return (status & SR_VS) == SR_VS_DIRTY;
}

5、阶段信息到调度参数的映射

阶段信息进入调度决策的主要入口为 task_slice() 和 task_boost()。task_slice() 决定本次入队时间片,task_boost() 决定任务停止运行后的 vtime 增长速度。普通任务使用 normal_slice_ns,V-hot 且无有效阶段提示的任务使用 vector_slice_ns;Prefill 使用 prefill_slice_ns,Decode 使用 decode_slice_ns;Mixed 阶段在 proportional 模式下按两类 token 数加权:

static u64 task_slice(bool vhot, u32 phase, u32 prefill_tokens,
              u32 decode_tokens)
{
    if (!vhot)
        return normal_slice_ns;
    if (phase == VSTATE_PHASE_PREFILL)
        return prefill_slice_ns;
    if (phase == VSTATE_PHASE_DECODE)
        return decode_slice_ns;
    if (phase == VSTATE_PHASE_MIXED) {
        u64 total = (u64)prefill_tokens + decode_tokens;
        if (total)
            return (prefill_slice_ns * prefill_tokens +
                decode_slice_ns * decode_tokens) / total;
        return decode_slice_ns;
    }
    return vector_slice_ns;
}

vtime boost 同样按阶段计算。Prefill 使用 prefill_boost,Decode 使用 decode_boost,Mixed 以 token 占比进行四舍五入加权:

static u64 task_boost(bool vhot, u32 phase, u32 prefill_tokens,
              u32 decode_tokens)
{
    if (!vhot)
        return 1;
    if (phase == VSTATE_PHASE_PREFILL)
        return prefill_boost ?: 1;
    if (phase == VSTATE_PHASE_DECODE)
        return decode_boost ?: 1;
    if (phase == VSTATE_PHASE_MIXED) {
        u64 total = (u64)prefill_tokens + decode_tokens;
        u64 weighted = (u64)(prefill_boost ?: 1) * prefill_tokens +
                   (u64)(decode_boost ?: 1) * decode_tokens;
        return (weighted + total / 2) / total;
    }
    return vector_boost ?: 1;
}

在该实现中,动态 PD 分离具体表现为时间片与 vtime 计费参数的分阶段计算。Prefill 阶段获得更长的连续运行窗口,Decode 阶段保留适度优先级但限制单次时间片长度,Mixed 阶段根据当前 batch 中 prefill/decode token 比例在两类参数之间加权。所有任务仍通过 DSQ vtime 排序,调度器未绕过 sched_ext 的公平计费边界。

6、调度生命周期:选核、入队、分发、运行、停止

select_cpu() 处理任务唤醒后的 CPU 选择。该函数先调用 task_phase()、task_is_vhot() 和 task_slice(),得到当前任务的阶段、V-hot 状态和本次时间片;若任务注册了 worker hint,则尝试稳定映射到对应 CPU;否则 V-hot 任务优先返回 last_cpu,并在目标 CPU 空闲时直接插入本地 DSQ:

u32 phase = task_phase(p, now, &hint_present, &prefill_tokens,
               &decode_tokens);
bool vhot = task_is_vhot(taskc, now, hint_present);
u64 slice = task_slice(vhot, phase, prefill_tokens, decode_tokens);

if (taskc && vhot &&
    taskc->last_cpu >= 0 &&
    bpf_cpumask_test_cpu(taskc->last_cpu, p->cpus_ptr) &&
    scx_bpf_test_and_clear_cpu_idle(taskc->last_cpu)) {
    scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, slice, 0);
    stat_inc(VSTAT_LOCAL_IDLE);
    return taskc->last_cpu;
}

若任务未能在 select_cpu() 中本地直派,enqueue() 将其放入共享 VSTATE_DSQ。该阶段首先限制 sleep credit,避免长时间睡眠任务在唤醒后获得过大的 vtime 优势;随后根据 sticky 策略决定是否回到上次 CPU。--sticky-adaptive 的实现逻辑是:当 home CPU 本地队列已有等待任务时,放弃硬粘性并回退到共享 DSQ,避免并行 worker 因单个 CPU 局部排队而影响整批计算:

if ((s64)(vtime - (vtime_now - max_credit)) < 0)
    vtime = vtime_now - max_credit;

if (task_should_stick(vhot, phase) && taskc && taskc->last_cpu >= 0 &&
    bpf_cpumask_test_cpu(taskc->last_cpu, p->cpus_ptr)) {
    if (sticky_mode == VSTATE_STICKY_ADAPTIVE &&
        scx_bpf_dsq_nr_queued(SCX_DSQ_LOCAL_ON |
                  taskc->last_cpu) != 0) {
        stat_inc(VSTAT_ADAPTIVE_QUEUE_FALLBACK);
        goto shared;
    }
    scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | taskc->last_cpu,
               slice, enq_flags);
    stat_inc(VSTAT_STICKY_LOCAL);
    return;
}

shared:
    scx_bpf_dsq_insert_vtime(p, VSTATE_DSQ, slice, vtime, enq_flags);

dispatch() 的职责限于在本地队列为空时,从共享 DSQ 搬运 vtime 最靠前的任务到当前 CPU 本地 DSQ:

void BPF_STRUCT_OPS(vstate_dispatch, s32 cpu, struct task_struct *prev)
{
    if (scx_bpf_dsq_move_to_local(VSTATE_DSQ))
        stat_inc(VSTAT_DISPATCHED);
}

running() 记录本次开始运行时间、迁核情况和阶段命中次数。Mixed 阶段额外统计 prefill 主导、decode 主导或相对均衡的 batch 组成,用于后续分析动态 batch 的阶段分布:

taskc->last_run_at_ns = now;
if (taskc->last_cpu >= 0 && taskc->last_cpu != cpu)
    stat_inc(VSTAT_MIGRATIONS);
taskc->last_cpu = cpu;

if (phase == VSTATE_PHASE_PREFILL)
    stat_inc(VSTAT_PREFILL_RUNS);
else if (phase == VSTATE_PHASE_DECODE)
    stat_inc(VSTAT_DECODE_RUNS);
else if (phase == VSTATE_PHASE_MIXED)
    stat_inc(VSTAT_MIXED_RUNS);

stopping() 完成任务停止运行后的状态更新:首先读取 VS=Dirty 并更新 V-hot 历史;随后统计本次阶段运行时间;最终调用 task_boost() 更新 p->scx.dsq_vtime。boost 越大,vtime 增长越慢,任务在共享 DSQ 中越容易保持靠前位置;所有任务仍按 vtime 排序,因此该策略属于 sched_ext 公平计费框架内的优先级倾斜:

dirty = task_vstate_dirty(p);
if (dirty) {
    taskc->last_dirty_ns = now;
    stat_inc(VSTAT_DIRTY_STOPS);
    stat_inc(runnable ? VSTAT_DIRTY_PREEMPTS : VSTAT_DIRTY_BLOCKS);
}

used = taskc->last_run_at_ns && now > taskc->last_run_at_ns ?
       now - taskc->last_run_at_ns : 0;
charge = used * 100 / (p->scx.weight ?: 100);

if (phase == VSTATE_PHASE_PREFILL)
    stat_add(VSTAT_PREFILL_RUNTIME_NS, used);
else if (phase == VSTATE_PHASE_DECODE)
    stat_add(VSTAT_DECODE_RUNTIME_NS, used);
else if (phase == VSTATE_PHASE_MIXED)
    stat_add(VSTAT_MIXED_RUNTIME_NS, used);

vhot = task_is_vhot(taskc, now, hint_present);
boost = task_boost(vhot, phase, prefill_tokens, decode_tokens);
p->scx.dsq_vtime += charge / boost;

7、loader 参数下发与目标线程接管

scx_vstate.c 的 loader 将命令行参数写入 BPF .rodata,随后加载并 attach struct_ops。--prefill-ms、--decode-ms、--decay-ms、--phase-policy、--sticky-adaptive 等参数最终成为 BPF 程序中的只读配置:

skel = SCX_OPS_OPEN(vstate_ops, scx_vstate);
skel->rodata->vector_slice_ns = vector_ms * NSEC_PER_MSEC;
skel->rodata->normal_slice_ns = normal_ms * NSEC_PER_MSEC;
skel->rodata->prefill_slice_ns = prefill_ms * NSEC_PER_MSEC;
skel->rodata->decode_slice_ns = decode_ms * NSEC_PER_MSEC;
skel->rodata->hot_decay_ns = decay_ms * NSEC_PER_MSEC;
skel->rodata->phase_hint_ttl_ns = hint_ttl_ms * NSEC_PER_MSEC;
skel->rodata->prefill_boost = prefill_weight;
skel->rodata->decode_boost = decode_weight;
skel->rodata->phase_policy = phase_policy;
skel->rodata->sticky_mode = sticky_mode;

partial 模式下,loader 设置 SCX_OPS_SWITCH_PARTIAL,并枚举 /proc/<tgid>/task,将目标进程的每个线程切换到 SCHED_EXT:

if (partial)
    skel->struct_ops.vstate_ops->flags |= SCX_OPS_SWITCH_PARTIAL;

link = SCX_OPS_ATTACH(skel, vstate_ops, scx_vstate);
if (partial) {
    for (unsigned int i = 0; i < nr_targets; i++)
        set_process_sched_ext(targets[i]);
}

目标 TGID 写入 target_tgids map 作为本轮调度目标声明;partial 模式下的线程切换动作由 set_process_sched_ext() 完成。loader 同时每 10 秒读取 stats per-CPU array 并输出累计统计:

for (unsigned int i = 0; i < nr_targets; i++)
    add_target_tgid(skel, targets[i]);

while (!exit_req && !UEI_EXITED(skel, uei)) {
    print_stats(skel);
    sleep(10);
}

板端运行时使用:

cd /home/kiara/bananaOS/dynamic_sched_ext
make -j"$(nproc)"

sudo ./build/scx_vstate \
  --partial \
  --sticky-adaptive \
  --phase-policy proportional \
  -p "$(pidof llama-server)"

与实验脚本配合时,run_exp.sh 同时负责调度器启动和 llama-server 环境变量注入:

ENABLE_SCX=1 \
SCX_SCHEDULER=dynamic \
ENABLE_PHASE_HINTS=1 \
PHASE_HINT_MAP=/sys/fs/bpf/scx_vstate/phase_hints \
ENABLE_VSTATE_PROFILER=1 \
CONCURRENCY=4 REQUESTS=4 \
  bash rvv_llm_lab/scripts/run_exp.sh

8、可观测性与正确性边界

调度器提供阶段命中、阶段运行时间和 sticky 策略行为的可观测字段。scx_vstate.h 中的 VSTAT_PREFILL_RUNS、VSTAT_DECODE_RUNS、VSTAT_MIXED_RUNS、VSTAT_PREFILL_RUNTIME_NS、VSTAT_DECODE_RUNTIME_NS、VSTAT_MIXED_RUNTIME_NS、VSTAT_STICKY_LOCAL、VSTAT_ADAPTIVE_QUEUE_FALLBACK 等统计项由 BPF 程序累加,并由 loader 输出到调度器日志。因此,阶段提示命中情况、Mixed 阶段的 token 主导类型、sticky-adaptive 的回退次数等信息均可通过 scheduler.log 观测和核对。

正确性边界方面,dynamic_sched_ext 不修改 Linux 的 RVV 保存/恢复函数,也不跳过必要的寄存器保存。调度器只在 sched_ext 允许的任务选择、DSQ 入队、时间片和 vtime 计费上调整策略;当 Dirty V 线程发生实际切出时,内核仍按原路径保存 V 状态。若 BPF 程序异常退出,sched_ext 通过 vstate_exit() 记录退出信息并回退;用户态 loader 卸载 link 后,系统恢复到普通调度状态。

3.5 llama.cpp 框架应用模块实现

本项目以 llama.cpp 的 llama-server 作为大模型推理服务,同一份 RISC-V RVV 二进制同时支持两套调度方案:静态 scx_vstate 方案不依赖应用侧提示,调度器仅依据内核在任务切出时观测到的 VS=Dirty 判断 V-hot 线程;动态 dynamic_sched_ext 方案在服务端 batch 执行路径加入轻量级阶段提示接口,在 llama_decode() 前后声明当前 batch 属于 Prefill、Decode 还是 Mixed 阶段。模型加载、token 采样、GGML 计算图和 RVV 量化算子仍沿用 llama.cpp 原有执行路径,新增逻辑仅提供可选调度提示,因此 Baseline、静态调度和动态调度可以使用同一模型、同一线程数和同一压测负载进行对照。

x86_64 / WSL2 交叉构建环境
        │
        │ build_llama_bpi.sh
        │ -march=rv64gcv... + GGML_RVV=ON
        ▼
RISC-V llama-server / llama-cli 静态产物
        │ 部署到 BananaPi BPI-F3
        ▼
加载 Llama-3.2-1B-Instruct-Q4_K_M.gguf
        │
        ├─ HTTP 层:/health、/v1/completions
        ├─ 服务层:parallel slots、连续批处理、SSE 流式返回
        ├─ 推理层:prefill 使用 threads-batch,decode 使用 threads
        ├─ 阶段提示层:server-vstate RAII scope 写 phase_hints
        └─ GGML CPU 后端:Q4_K_M 量化算子与 RVV 1.0 指令
                           │
                           ▼
                 Linux 推理工作线程
                           │
                           └─ 接受 CFS、静态 scx_vstate 或动态 dynamic_sched_ext 调度

1、面向 BPI-F3 的 RVV 交叉构建

rvv_llm_lab/scripts/build_llama_bpi.sh 统一指定 SpacemiT 交叉工具链、目标 ISA 和 llama.cpp 构建组件。目标 ISA 与板端 X60 内核暴露的扩展保持一致,启用标准 RVV、标量与向量半精度扩展;同时关闭本项目不使用的 GPU、OpenMP、测试和 Web UI 等组件,减少部署依赖。-fno-tree-vectorize 与 -fno-tree-loop-vectorize 用于避免编译器在非关键通用代码中自动引入额外向量化,使主要 RVV 行为集中于 GGML 已实现的向量内核,便于分析推理负载的 V 状态开销。

ARCH_FLAGS="-march=rv64gcv_zfh_zvfh_zba_zbb_zbs_zihintpause \
-mabi=lp64d -fno-tree-vectorize -fno-tree-loop-vectorize"

env RISCV_ROOT_PATH="$RISCV_ROOT_PATH" cmake \
    -S "$LLAMA_DIR" \
    -B "$BUILD_DIR" \
    -G Ninja \
    -DCMAKE_TOOLCHAIN_FILE="$LLAMA_DIR/cmake/riscv64-spacemit-linux-gnu-gcc.cmake" \
    -DCMAKE_BUILD_TYPE=Release \
    -DCMAKE_C_FLAGS="$ARCH_FLAGS" \
    -DCMAKE_CXX_FLAGS="$ARCH_FLAGS" \
    -DGGML_NATIVE=OFF \
    -DGGML_RVV=ON \
    -DGGML_RV_ZFH=ON \
    -DGGML_RV_ZVFH=ON \
    -DGGML_RV_ZICBOP=OFF \
    -DGGML_RV_ZIHINTPAUSE=ON \
    -DGGML_CPU_RISCV64_SPACEMIT=OFF \
    -DGGML_OPENMP=OFF \
    -DBUILD_SHARED_LIBS=OFF \
    -DLLAMA_BUILD_SERVER=ON \
    -DLLAMA_BUILD_TOOLS=ON \
    -DLLAMA_BUILD_TESTS=OFF \
    -DLLAMA_BUILD_EXAMPLES=OFF \
    -DLLAMA_BUILD_UI=OFF

cmake --build "$BUILD_DIR" \
    --target llama-server llama-cli \
    --parallel "${BUILD_JOBS:-$(nproc)}"

llama.cpp 的 ggml/src/ggml-cpu/CMakeLists.txt 根据上述选项组合 RISC-V 的 -march,并将量化与 repack 实现加入 CPU 后端。因此,项目最终使用 GGML 原生 RISC-V 量化计算路径,并非在板端模拟向量指令。

list(APPEND GGML_CPU_SOURCES
    ggml-cpu/arch/riscv/quants.c
    ggml-cpu/arch/riscv/repack.cpp
)

set(MARCH_STR "rv64gc")
if (GGML_RVV)
    string(APPEND MARCH_STR "v")
endif()
if (GGML_RV_ZFH)
    string(APPEND MARCH_STR "_zfh")
endif()
if (GGML_RV_ZVFH)
    string(APPEND MARCH_STR "_zvfh")
endif()
list(APPEND ARCH_FLAGS "-march=${MARCH_STR}" -mabi=lp64d)

2、GGUF 模型加载与推理服务实例化

rvv_llm_lab/bin/run_server.sh 将模型路径、生成线程数、批处理线程数、并行 slot 数和上下文长度转换为 llama.cpp 命令行参数。正式实验固定 THREADS=4、THREADS_BATCH=4 和 CTX_SIZE=1024,仅随实验档位改变 PARALLEL,从而使 Baseline、静态 scx_vstate 与动态 dynamic_sched_ext 组的框架参数一致。exec 使脚本进程直接替换为 llama-server,实验编排器记录到的 PID 即服务进程 TGID,可直接传递给 V 状态观测模块和两类 sched_ext 调度器。

SERVER=${LLAMA_SERVER:-"$ROOT_DIR/llama.cpp/build-bpi-rvv/bin/llama-server"}
MODEL=${MODEL:-"$ROOT_DIR/llama.cpp/models/Llama-3.2-1B-Instruct-Q4_K_M.gguf"}
THREADS=${THREADS:-4}
THREADS_BATCH=${THREADS_BATCH:-4}
PARALLEL=${PARALLEL:-8}
CTX_SIZE=${CTX_SIZE:-1024}

exec "$SERVER" \
    -m "$MODEL" \
    -t "$THREADS" \
    -c "$CTX_SIZE" \
    --host "$HOST" \
    --port "$PORT" \
    --threads-batch "$THREADS_BATCH" \
    --parallel "$PARALLEL" \
    "$@"

llama.cpp 内部将 --threads 用于逐 token 生成阶段,将 --threads-batch 用于 batch 和 prompt 处理,并通过 --parallel 建立并行服务 slot。三者分别控制 decode 工作线程、prefill 工作线程和可同时驻留的请求序列,是本项目建立统一并发基线的核心参数。

add_opt(common_arg(
    {"-t", "--threads"}, "N",
    "number of CPU threads to use during generation",
    [](common_params & params, int value) {
        params.cpuparams.n_threads = value;
    }
));

add_opt(common_arg(
    {"-tb", "--threads-batch"}, "N",
    "number of threads to use during batch and prompt processing",
    [](common_params & params, int value) {
        params.cpuparams_batch.n_threads = value;
    }
));

add_opt(common_arg(
    {"-np", "--parallel"}, "N",
    "number of server slots",
    [](common_params & params, int value) {
        params.n_parallel = value;
    }
));

3、OpenAI 兼容接口与流式输出

服务端在 HTTP 层注册健康检查和 OpenAI 兼容补全接口。实验脚本轮询 /health,在确认模型加载完成后启动 observer 和 loadgen,防止模型加载时间进入 TTFT 统计;压测请求统一发送到 /v1/completions。

ctx_http.get ("/health",         ex_wrapper(routes.get_health));
ctx_http.post("/completion",     ex_wrapper(routes.post_completions));
ctx_http.post("/v1/completions", ex_wrapper(routes.post_completions_oai));

请求进入服务端后被分配到一个 slot,slot 状态依次经历 STARTED、PROCESSING_PROMPT、DONE_PROMPT 和 GENERATING。每得到一个有效 token,process_token() 将文本追加到该 slot 的生成结果;当请求设置 stream=true 时立即发送局部响应。loadgen 因而能够以收到第一个非空 SSE 文本片段的时刻作为首 token 到达时刻。

slot.state = slot.task->is_child()
    ? SLOT_STATE_WAIT_OTHER
    : SLOT_STATE_STARTED;

bool process_token(completion_token_output & result, server_slot & slot) {
    const std::string token_str = result.text_to_send;
    slot.sampled = result.tok;
    slot.generated_text += token_str;
    slot.has_next_token = true;

    // 省略停止词和 UTF-8 完整性检查
    slot.add_token(result);
    if (slot.task->params.stream) {
        send_partial_response(slot, result, false);
    }
    return true;
}

4、llama-server 主动阶段提示接入

动态 PD 分离调度器需要获得当前 llama_decode() 正在执行 Prefill、Decode 还是混合 batch。本项目在 llama-server 侧加入 server-vstate.h 和 server-vstate.cpp 两个轻量文件,通过 Linux bpf() syscall 打开调度器 pin 到 bpffs 的 BPF map:

/sys/fs/bpf/scx_vstate/phase_hints

该接口默认关闭,仅在设置 LLAMA_VSTATE_PHASE_HINTS=1 时写入;LLAMA_VSTATE_PHASE_MAP 可覆盖默认 map 路径。因此,Baseline 和静态 scx_vstate 实验可以运行同一份 llama-server 二进制但不发送提示,动态调度实验通过环境变量打开提示通道。

接入位置集中在 tools/server/:构建系统将 server-vstate.cpp 和 server-vstate.h 加入 server-context 静态库,server-context.cpp 引入该头文件;server_batch::token 增加阶段字段,server_batch::add() 在加入 prompt token 时写 prefill,在加入采样 token 或 draft token 时写 decode。因此,阶段判断来自 llama-server 已有的 batch 构造语义,无需在调度器中根据线程名或 CPU 利用率推断推理阶段。

class phase_map_client {
public:
    phase_map_client() {
        const char * enabled = std::getenv("LLAMA_VSTATE_PHASE_HINTS");
        enabled_ = enabled && std::strcmp(enabled, "1") == 0;

        const char * path = std::getenv("LLAMA_VSTATE_PHASE_MAP");
        map_path_ = path && path[0] ? path : default_map_path;
    }

    bool set(server_vstate_phase phase,
             uint32_t prefill_tokens,
             uint32_t decode_tokens);
};

阶段提示的 value 与 BPF 调度器共享同一 ABI。用户态写入 abi_version、phase、updated_ns、prefill_tokens、decode_tokens 和 batch_tokens,调度器侧再做版本、时间戳和 token 一致性校验。

struct phase_hint {
    uint32_t abi_version;
    uint32_t phase;
    uint64_t updated_ns;
    uint32_t prefill_tokens;
    uint32_t decode_tokens;
    uint32_t batch_tokens;
    uint32_t reserved;
};

在 server batch 管理中,每个进入 batch 的 token 均带有阶段标记:prompt/prefill token 标记为 prefill,采样后继续生成的 token 和 speculative draft token 标记为 decode。在调用 llama_decode(ctx_tgt, batch_view) 前,get_vstate_workload() 统计当前 batch view 中两类 token 数,并由 RAII scope 写入阶段提示;llama_decode() 返回后 scope 析构,将进程阶段状态恢复为 IDLE。

int ret;
{
    server_vstate_phase_scope phase_scope(
        batch.get_vstate_workload(off, batch_view.n_tokens));
    ret = llama_decode(ctx_tgt, batch_view);
}

当前 batch 仅含 prompt token 时,提示为 PREFILL;仅含生成 token 时,提示为 DECODE;两类 token 同时存在时提示为 MIXED,并同时写入 prefill/decode token 数。调度器据此对 Mixed 阶段进行比例加权,避免将连续批处理中的混合 batch 固定归为单一阶段。

基于上述实现,llama.cpp 服务模块同时具备可控制线程数、并发 slot、输入输出规模和阶段提示的能力。其计算线程既是 vstate_profiler 的观测目标,也是静态 scx_vstate 与动态 dynamic_sched_ext 的调度目标;当阶段提示开启时,它同时作为 Prefill/Decode 动态调度提示的来源。

3.6 大模型应用与压测模块实现

大模型应用与压测模块位于 rvv_llm_lab/,以 scripts/run_exp.sh 为入口,将 llama-server 生命周期、sched_ext 调度器、阶段提示、固定并发负载、V 状态观测和结果汇总串联为一个 run ID。脚本支持 Baseline、SCX_SCHEDULER=vstate 静态 scx_vstate、SCX_SCHEDULER=dynamic 动态 dynamic_sched_ext 三类运行模式,并可按需打开 llama-server 阶段提示。

实验参数
CONCURRENCY / REQUESTS / MAX_TOKENS / THREADS / MODEL / ENABLE_SCX / ENABLE_PHASE_HINTS
        │
        ▼
run_exp.sh 创建 results/<run_id>/ 并保存环境快照
        │
        ├─ 按需 env 注入 LLAMA_VSTATE_PHASE_HINTS / LLAMA_VSTATE_PHASE_MAP
        │
        ├─ 启动 llama-server ──轮询 /health──┐
        │                                     │
        ├─ ENABLE_SCX=1 时按 SCX_SCHEDULER 启动调度器
        │      ├─ vstate: sched_ext/vstate/scx_vstate -p <SERVER_PID>
        │      └─ dynamic: dynamic_sched_ext/scx_vstate -p <SERVER_PID>
        │                                     │
        ├─ 启动 vstate_observer(绑定 TGID)   │
        ├─ 启动 vstate_profiler 统计窗口       │
        │                                     ▼
        └────────────── loadgen.py ──异步并发请求
                              │
                              ├─ 固定 prompt 与输出长度
                              ├─ SSE 首片段时间 → TTFT
                              ├─ 请求结束时间 → Latency
                              └─ 输出事件数/墙钟时间 → TPS
                                             │
                                             ▼
     scheduler.log + bench.json + vstate.json + run_meta.json
                                             │
                                             ▼
                         analyze.py → results/summary.csv
                                             │
                                             ▼
                     plot_results.py → 均值、标准差与对比图

1、固定输入负载与异步并发控制

benchmark/loadgen.py 在未指定 prompt 文件时调用 make_fixed_prompt() 生成确定性的固定词数文本。所有请求复用完全相同的字符串,并使用 temperature=0 和相同的 max_tokens,从而减少输入分布和采样随机性对调度实验的干扰。需要说明的是,本实验固定的是 HTTP 输入字符串和词数,而不是 tokenizer 处理后的精确 token 数;程序同时将字符数和词数写入结果,保证各组可核查。

def make_fixed_prompt(words: int) -> str:
    prefix = [
        "Explain", "RISC-V", "Vector", "scheduling",
        "for", "LLM", "inference.",
    ]
    filler = [
        "vector", "state", "context", "switch",
        "latency", "throughput", "thread", "scheduler",
    ]
    tokens: list[str] = []

    while len(tokens) < words:
        source = prefix if not tokens else filler
        for word in source:
            tokens.append(word)
            if len(tokens) == words:
                break
    return " ".join(tokens)

每个请求构造相同的 OpenAI Completion JSON。asyncio.Semaphore 限制同时进行的请求数,asyncio.gather() 等待本轮所有请求完成;当 REQUESTS 大于 CONCURRENCY 时,请求按并发窗口分批执行。连接设置为 force_close=True,避免长时间流式响应结束后复用已被服务端关闭的 HTTP/1.1 连接而产生非服务端计算失败。

payload = {
    "model": model,
    "prompt": prompt,
    "max_tokens": max_tokens,
    "temperature": 0,
    "stream": True,
}

semaphore = asyncio.Semaphore(args.concurrency)
connector = aiohttp_mod.TCPConnector(
    limit=max(args.concurrency * 2, 16),
    force_close=True,
)

tasks = [
    one_request(
        session=session,
        url=args.url.rstrip("/") + "/v1/completions",
        model=args.model_name,
        prompt=prompts[i % len(prompts)],
        max_tokens=args.max_tokens,
        request_id=i,
        semaphore=semaphore,
    )
    for i in range(args.requests)
]
results = await asyncio.gather(*tasks)

2、SSE 解析与 TTFT、Latency 采集

每个请求在获得 semaphore 后记录单调时钟 start_ns,避免排队等待并发名额的时间被误计入服务端延迟。程序按行解析 SSE 数据,兼容 /v1/completions 的 choices[0].text 和 chat 风格的 delta.content;收到第一个非空文本片段时仅记录一次 first_token_ns,流结束时记录 end_ns。单个请求失败不影响其他协程执行,而是写入 error 字段;整轮结束后若存在错误则返回非零状态,防止残缺数据进入正式结果。

async with semaphore:
    start = now_ns()
    first = 0
    token_events = 0

    async with session.post(url, json=payload) as resp:
        pending = b""
        async for raw in resp.content.iter_chunked(4096):
            pending += raw
            while b"\n" in pending:
                line, pending = pending.split(b"\n", 1)
                obj = parse_sse_json(line)
                if not obj:
                    continue
                piece = extract_piece(obj)
                if piece:
                    if first == 0:
                        first = now_ns()
                    token_events += 1
        end = now_ns()

return {
    "start_ns": start,
    "first_token_ns": first,
    "end_ns": end,
    "ttft_ms": (first - start) / 1_000_000.0,
    "latency_ms": (end - start) / 1_000_000.0,
    "output_tokens": token_events,
    "error": error,
}

SSE 事件可能包含一个或多个模型 token,因此 output_tokens 在当前实现中表示“非空流式文本事件数”。本项目的 Baseline 和优化组使用同一版本 server、同一解析器和同一输入输出配置,该值可作为相对吞吐指标;正式解释绝对 token/s 时,应与 llama.cpp 服务端官方 token 计数交叉验证。

3、整轮性能指标计算

loadgen 对所有成功请求计算 TTFT 均值、P95 TTFT 和平均总延迟。P95 使用相邻次序统计量线性插值;吞吐量以所有成功请求的输出事件总数除以 asyncio.gather() 外层记录的整轮墙钟时间,能够覆盖并发请求彼此重叠的真实服务区间。

def percentile(values: list[float], p: float) -> float:
    ordered = sorted(values)
    k = (len(ordered) - 1) * p / 100.0
    lo = math.floor(k)
    hi = math.ceil(k)
    if lo == hi:
        return ordered[int(k)]
    return ordered[lo] * (hi - k) + ordered[hi] * (k - lo)

ttfts = [r["ttft_ms"] for r in ok]
latencies = [r["latency_ms"] for r in ok]
total_tokens = sum(int(r["output_tokens"]) for r in ok)
duration_sec = (wall_end - wall_start) / 1_000_000_000.0
aggregate_tps = total_tokens / duration_sec if duration_sec > 0 else 0.0

result = {
    "aggregate_tps": aggregate_tps,
    "mean_ttft_ms": statistics.mean(ttfts) if ttfts else 0.0,
    "p95_ttft_ms": percentile(ttfts, 95),
    "mean_latency_ms": statistics.mean(latencies) if latencies else 0.0,
    "error_count": len(errors),
}

4、一键实验生命周期编排

scripts/run_exp.sh 每次执行仅产生一轮独立实验。脚本首先保存内核版本、模型、线程数、并发数、调度器模式和 phase hint 配置,然后以环境变量形式启动 llama-server。服务健康检查通过后,脚本将真实 SERVER_PID 传给当前选定调度器的 -p 参数:静态方案加载 sched_ext/vstate/build/scx_vstate,动态方案加载 dynamic_sched_ext/build/scx_vstate 并在开启 hints 时消费 phase map。由此,两种调度器均可在 partial 模式下仅接管本轮推理服务线程;随后启动 observer、profiler 和 loadgen。压测完成后,脚本按相反顺序停止 profiler、observer、scheduler 和 server,并归档所有日志。

env \
    "LLAMA_VSTATE_PHASE_HINTS=$ENABLE_PHASE_HINTS" \
    "LLAMA_VSTATE_PHASE_MAP=$PHASE_HINT_MAP" \
    "$LLAMA_SERVER" \
    --model "$MODEL" \
    --host "$HOST" \
    --port "$PORT" \
    --threads "$THREADS" \
    --threads-batch "$THREADS_BATCH" \
    --parallel "$PARALLEL" \
    --ctx-size "$CTX_SIZE" \
    >"$OUT_DIR/server.log" 2>&1 &
SERVER_PID=$!

wait_health
start_scx_scheduler "$SERVER_PID"
write_run_env

sudo "$OBSERVER" \
    --pid "$SERVER_PID" \
    --out "$OBSERVER_OUT" \
    --bpf-obj "$ROOT_DIR/ebpf/vstate_observer.bpf.o" \
    >"$OUT_DIR/observer.log" 2>&1 &
OBSERVER_PID=$!

configure_and_start_profiler "$SERVER_PID"
write_run_meta

LOADGEN_ARGS=(
  python3 "$ROOT_DIR/benchmark/loadgen.py" \
    --url "$URL" \
    --concurrency "$CONCURRENCY" \
    --requests "$REQUESTS" \
    --max-tokens "$MAX_TOKENS" \
    --fixed-prompt-words "$FIXED_PROMPT_WORDS" \
    --out "$LOADGEN_OUT" \
    --run-id "$RUN_ID" \
    --model-name "$MODEL_NAME"
)
"${LOADGEN_ARGS[@]}"

stop_profiler_and_set_requests
collect_vstate_profiler_stats

kill -INT "$OBSERVER_PID"
wait "$OBSERVER_PID"

stop_scx_scheduler

python3 "$ROOT_DIR/analysis/analyze.py" \
    --bench "$LOADGEN_OUT" \
    --vstate "$OBSERVER_OUT" \
    --profiler-stats "$PROFILER_STATS_OUT" \
    --meta "$OUT_DIR/run_meta.json" \
    --out "$SUMMARY_CSV"

脚本用 ENABLE_SCX、SCX_SCHEDULER、ENABLE_PHASE_HINTS 和 ENABLE_VSTATE_PROFILER 组合实验模式。SCX_SCHEDULER=vstate 或 scx_vstate 选择 sched_ext/vstate/build/scx_vstate,用于验证纯内核 Dirty V-hot 反馈策略;SCX_SCHEDULER=dynamic 选择 dynamic_sched_ext/build/scx_vstate,默认参数为 --partial --sticky-adaptive --phase-policy proportional;ENABLE_PHASE_HINTS=1 同时向 llama-server 注入 LLAMA_VSTATE_PHASE_HINTS=1,形成“应用声明阶段、调度器消费提示”的闭环。在手动管理模式下,可设置 SCX_MANAGE=0,此时脚本仅记录外部调度器状态。

experiment_mode() {
  if [[ "$ENABLE_SCX" == "1" && "$ENABLE_VSTATE_PROFILER" == "1" ]]; then
    echo "scx_profiler"
  elif [[ "$ENABLE_SCX" == "1" ]]; then
    echo "scx"
  elif [[ "$ENABLE_VSTATE_PROFILER" == "1" ]]; then
    echo "profiler"
  else
    echo "baseline"
  fi
}

5、结果合并与重复实验校验

analysis/analyze.py 以 run_id 为主键,将 loadgen_result.json、observer.json、profiler debugfs 快照和 run_meta.json 合并为一行 CSV。V 状态数据按“内核模块、内核 debugfs、eBPF 辅助值”的顺序选择可用数据源,并在同一记录中给出 TPS、TTFT、save/restore 次数、总耗时、迁核次数及实验模式,避免人工抄录造成错配。

if profiler_stats:
    source = profiler_stats
    vstate_probe_mode = (
        "module_save_restore"
        if restore_hotpath_supported
        else "module_save_only_lower_bound"
    )
elif rvv_stats:
    source = rvv_stats
    vstate_probe_mode = "kernel_debugfs"
else:
    source = {}
    vstate_probe_mode = str(vstate.get("probe_mode", "ebpf_fallback"))

row = {
    "run_id": bench.get("run_id", ""),
    "concurrency": bench.get("concurrency", ""),
    "experiment_mode": meta.get("experiment_mode", "baseline"),
    "save_count": save_count,
    "restore_count": restore_count,
    "vstate_total_ns": vstate_total_ns,
    "aggregate_tps": fmt(float(bench.get("aggregate_tps", 0.0))),
    "mean_ttft_ms": fmt(float(bench.get("mean_ttft_ms", 0.0))),
    "p95_ttft_ms": fmt(float(bench.get("p95_ttft_ms", 0.0))),
}

正式评测可分别执行 2、4、8 并发的 Baseline、静态 scx_vstate 和动态 dynamic_sched_ext 配置。静态优化组使用 ENABLE_SCX=1、SCX_SCHEDULER=vstate,无需 ENABLE_PHASE_HINTS;动态优化组使用 ENABLE_SCX=1、SCX_SCHEDULER=dynamic 和 ENABLE_PHASE_HINTS=1。每个组合至少生成 3 个独立 run ID。plot_results.py 面向 Baseline 与静态 scx_vstate 的成对对比;多调度器批量实验则由 scripts/run_exp.sh -s none,vstate,dynamic 和 scripts/summarize_runs.py 输出按 scheduler 分组的均值、标准差。

CONCURRENCIES = (2, 4, 8)
MODES = ("Baseline", "scx_vstate")

def validate(runs: list[dict[str, Any]]) -> None:
    problems = []
    for concurrency in CONCURRENCIES:
        for mode in MODES:
            count = sum(
                r["concurrency"] == concurrency and r["mode"] == mode
                for r in runs
            )
            if count != 3:
                problems.append(
                    f"c={concurrency}, mode={mode}: expected 3 runs, found {count}"
                )
    if problems:
        raise ValueError("Incomplete experiment matrix:\n" + "\n".join(problems))

def stats(sample: list[float]) -> tuple[float, float]:
    return statistics.mean(sample), statistics.stdev(sample)

上述流程形成“统一配置启动—固定并发负载—同步采集应用与内核指标—独立 run ID 留档—重复性统计”的闭环,使大模型性能变化可以与同一轮的 V 状态保存、调度切换和迁核行为对应分析。

3.7 可选完成项d.与 x86(AVX2/AVX-512)平台进行横向对比

本项目在 x86_avx2/ 中实现了 x86_64 / WSL2 / AVX2 对照实验。该实验复用 rvv_llm_lab/benchmark/loadgen.py,保持模型、并发、请求数、THREADS=4、THREADS_BATCH=4、CTX_SIZE=1024 和 MAX_TOKENS=64 等口径一致;同时额外采集 /proc 线程上下文切换、perf stat 可用性和 CPUID leaf 0xD 暴露的 XSAVE/xstate 状态大小。由于 WSL2 对 PMU、kprobe 和部分 perf 事件有限制,本节 x86 数据仅作为机制对照和趋势对照,不作为裸机性能结论。

本机 x86 对照平台为 Intel Core i9-13900HX,xstate_info.json 显示 XCR0=0x7,启用了 x87、SSE 和 AVX 状态;当前可用 XSAVE area 为 832 B,其中 AVX2 对应的 AVX_YMM_Hi128 组件为 256 B。因此,该批实验是 AVX2 实测对照,未启用 AVX-512。在启用 AVX-512 的 x86 平台上,opmask、ZMM_hi256 和 Hi16_ZMM 等状态组件会进一步扩大 XSAVE area,但其大小仍由 CPUID/XCR0 固定给出。

x86_avx2/experiments/x86-avx2/20260722T081031Z-x86-avx2/group_summary.csv 中每个并发档位重复 3 次,汇总结果如下:

并发 TPS 均值±标准差 平均 TTFT(ms) P95 TTFT(ms) 进程上下文切换 非自愿切换 切换/请求 估算 xstate/请求
2 65.36±0.21 273.15±28.94 1014.34±84.57 1110.67±21.13 42.67±1.15 138.83±2.64 112.8±2.1 KiB
4 76.99±0.67 929.49±96.57 2018.51±83.53 2814.33±65.96 88.33±85.59 351.79±8.24 285.8±6.7 KiB
8 62.58±0.69 3372.80±330.57 4079.18±58.36 7067.33±433.30 45.33±5.03 883.42±54.16 717.8±44.0 KiB

x86 对照结果显示,随着并发从 2 增加到 8,进程上下文切换均值从约 1111 次增加到约 7067 次,切换/请求从 138.83 增加到 883.42,约为 6.36 倍;由于 AVX2 的 XSAVE area 固定为 832 B,估算 xstate 数据搬运量也按相同比例从约 112.8 KiB/请求增加到约 717.8 KiB/请求。因此,在固定长度 SIMD 架构上,扩展状态压力主要由“发生多少次切换”决定,单次状态大小在同一平台上是稳定常数。

RVV 的特殊性在于 VLA 编程模型使同一份用户态二进制与固定向量长度解耦,但操作系统保存对象并非抽象的 VLA 语义,而是硬件真实 VLEN 对应的寄存器映像。对于本项目的 SpacemiT K1,vlen_bits=256,一次完整 V 寄存器保存的理论数据量为:

32 * VLEN / 8 = 32 * 256 / 8 = 1024 B

因此,在相同 Dirty 切出次数下,K1 的 RVV V 寄存器保存字节数约为当前 AVX2 启用 XSAVE area 的 1024 / 832 = 1.23 倍;如果只比较 AVX2 额外的 YMM 高 128 位组件,则为 1024 / 256 = 4 倍。此外,RVV 该项开销随 VLEN 线性增长:VLEN=512 时仅 V 寄存器为 2048 B,约为当前 AVX2 XSAVE area 的 2.46 倍;VLEN=1024 时为 4096 B,约为 4.92 倍。应用层仍可使用同一套 VLA 代码,但调度器和内核上下文切换路径面对的实际保存成本随硬件实现而变化。

综上,RVV VLA 的调度问题具有两层放大效应:第一层与 x86 AVX2 相同,并发升高会增加上下文切换频率;第二层是 RVV 特有的,单次 Dirty V 状态保存成本同时与硬件 VLEN 绑定。同样是 256 bit 向量计算,AVX2 的状态大小由 ISA 和 XCR0 固定,而 RVV 的 OS 保存规模按实现定义的 VLEN 变化。对于 LLM 推理这种长时间保持 vector dirty 的负载,减少可运行 V-hot 线程的短时间片抢占和无效迁核,比单纯限制 CPU affinity 更有必要。因此,本项目在 RISC-V 真机上实现 scx_vstate 与动态阶段提示调度器。

4、实验

4.1 模型量化实验

模型规模:

  • Qwen2.5-0.5B-Instruct Q4_K_M
  • Qwen2.5-1.5B-Instruct Q4_K_M
  • Qwen2.5-3B-Instruct Q4_K_M

bd9dc3199d6cf327224c7e84cf423e8f

560f3fec5fffe871d495027e294ac2be

4a9f07394e737adac5fbacfd69b7ab17

从图中可以得出模型量化的结论如下:

  1. 在相同并发下,模型规模越大,V 状态保存次数越高

    Qwen2.5 3B 的 V-State save count 明显高于 1.5B 和 0.5B,说明更大模型会带来更持续、更密集的 RVV 计算,从而增加 Dirty V 线程被切出时的保存压力。

  2. 并发数增加会显著放大 V 状态保存压力

    0.5B、1.5B、3B 三条曲线都随并发 2→4→8 上升,说明并发请求越多,推理线程之间的调度竞争越强,V 状态保存次数也越多。

  3. TTFT 对模型规模和并发都非常敏感,且 3B 模型尾延迟增长最明显

    平均 TTFT 和 P95 TTFT 都随模型规模、并发数上升而上升;其中 3B 在 8 并发下 P95 TTFT 接近 90s,说明大模型在当前板端资源下已经进入明显排队和调度竞争状态。

  4. 0.5B 与 1.5B 的 TTFT 差距相对小于 3B 与前两者的差距

    这说明在 BPI-F3 上,小模型和中等模型仍处于相对可控范围,而 3B 模型会显著放大计算负载、V 状态开销和尾延迟。

4.2 调度器实验环境

​ 正式性能测试在 BananaPi BPI-F3 真机上完成,处理器为 SpacemiT K1八核 RISC-V,支持 RVV 1.0,VLEN=256;操作系统为 Armbian,内核版本为6.18.37-current-spacemit,CPU 频率策略固定为 performance governor。

​ 测试模型统一采用 Qwen2.5-0.5B-Instruct Q4_K_M,llama.cpp 设置。THREADS=8THREADS_BATCH=8、CTX_SIZE=4096,输入长度固定为 64 词,最大输出长度为 64 Token。每轮发送 16 个内容不同但长度一致的请求,并发数和llama-server 的 parallel 参数分别设为 2、4、8。

​ Baseline 关闭 sched_ext 与阶段提示,使用 Linux 默认调度策略;优化组启用V-hot 用户态—内核态协同调度,使用 proportional 阶段策略和 adaptive CPU粘性,Prefill 配置为 50 ms/3×,Decode 配置为 30 ms/2×。

​ 每种并发和策略均独立运行 3 轮。以下数据均采用“均值 ± 样本标准差”表示,标准差使用样本标准差计算,即分母为 n-1。

类别 具体规格
开发板 BananaPi_BPI-F3
操作系统 Armbian(基于国产操作系统Bianbu进行构建)
内核版本 6.18.37-current-spacemit
芯片(SOC) 进迭时空(SpacemiT) K1 8核RISC-V芯片
CPU架构 riscv64
指令集 rv64imafdcv(支持V扩展)
交叉编译工具链 riscv64-unknown-linux-gnu-
模型 Qwen2.5-0.5B-Instruct Q4_K_M

4.3 调度器实验基线与实验指标

默认模型为 Qwen2.5-0.5B-Instruct Q4_K_M。当前实验记录中的文件大小约为 490 MiB;文件类型为量化 GGUF 的磁盘模式,模型参数量不超过 3B,符合赛题限制。实验方面,Baseline 组不加载调度器默认采用CFS,优化组分别加载 scx_vstate与dynamic调度器,三组均使用相同版本的 profiler 和 observer。正式评测环境为 BananaPi BPI-F3、SpacemiT K1、Armbian(bianbu)、6.18.37-current-spacemit。

以下是调度器实验的相关指标说明:

  • V状态相关字段
字段 含义
save_count(V-State Save Count) 进程实际进入 V 状态保存路径的次数
vstate_total_ns(V-State Save Time) 已观测的 V 状态总时间开销
vstate_ns_per_reques(V-State Saves per Second) 每成功请求V状态保存时间开销
  • 大模型相关字段
字段 含义
aggregate_tps(Throughput) 吞吐量
mean_ttft_ms(Mean TTFT) 从发出请求到收到第一个 token
p95_ttft_ms(P95 TTFT) 95% 请求的首 token 延迟

4.4 调度器实验结果

  • 实验参数
参数名称 参数含义 参数值
THREADS 推理线程数 8
REQUESTS 每轮实验请求总数 16
CTX_SIZE 上下文窗口长度 4096 token
FIXED_PROMPT_WORDS 每条 prompt 的输入规模 64 token
CONCURRENCY 单轮实验并发数 2 / 4 / 8
MODEL_NAME 进行推理的模型名称 Qwen2.5-0.5B-Instruct Q4_K_M
  • 推理性能实验结果
并发 策略 TPS 平均 TTFT(ms) P95 TTFT(ms) 平均延迟(ms)
2 Baseline 5.520 ± 0.200 9,370.05 ± 388.90 10,140.80 ± 268.89 23,209.24 ± 856.10
2 协同调度 6.051 ± 0.068 8,744.57 ± 107.49 9,489.83 ± 179.89 21,152.89 ± 237.85
4 Baseline 6.372 ± 0.056 17,319.37 ± 290.36 20,043.88 ± 213.49 40,172.44 ± 356.21
4 协同调度 7.075 ± 0.104 16,838.28 ± 940.65 18,382.83 ± 204.90 36,183.45 ± 533.86
8 Baseline 6.531 ± 0.166 32,998.84 ± 2,573.12 39,409.94 ± 1,351.26 78,383.69 ± 1,988.03
8 协同调度 7.343 ± 0.163 28,869.49 ± 5,054.16 35,125.13 ± 1,170.70 69,691.70 ± 1,486.19
  • RVV V 状态开销实验结果

​ 使用 vstate_profiler 可加载内核模块统计 RISC-V Vector 状态保存路径的调用次数与执行耗时,并结合调度 tracepoint 统计 llama-server 线程的调度切换次数。

并发 策略 V 状态保存次数 保存总耗时(ms) 每秒保存次数 llama 调度切换次数
2 Baseline 146,063 ± 12,054 400.29 ± 18.42 772.13 ± 84.84 225,964 ± 6,627
2 协同调度 96,374 ± 8,394 289.91 ± 4.45 556.43 ± 45.22 174,194 ± 14,950
4 Baseline 131,773 ± 9,417 309.25 ± 12.63 800.98 ± 63.91 209,375 ± 14,302
4 协同调度 86,656 ± 7,341 213.96 ± 15.43 582.92 ± 47.99 165,422 ± 14,412
8 Baseline 130,339 ± 9,029 308.01 ± 12.44 811.92 ± 72.58 201,559 ± 6,093
8 协同调度 85,627 ± 12,406 207.19 ± 25.77 597.70 ± 93.89 158,602 ± 18,974
  • 优化效果

​ 相较统一 Baseline,协同调度在 2、4、8 并发下分别取得以下改善:

并发 TPS 平均 TTFT P95 TTFT 平均延迟 V 状态保存次数 保存总耗时 调度切换次数
2 +9.63% -6.68% -6.42% -8.86% -34.02% -27.57% -22.91%
4 +11.03% -2.78% -8.29% -9.93% -34.24% -30.81% -20.99%
8 +12.44% -12.51% -10.87% -11.09% -34.30% -32.73% -21.31%

​ 其中,TPS 的正数表示吞吐提升,其余指标的负数表示开销或延迟下降。结果表明,优化方案在全部并发档位均取得可重复的收益。随着并发压力增大,吞吐提升由9.63% 增至 12.44%,P95 TTFT 降幅由 6.42% 增至 10.87%,V 状态保存总耗时降幅由 27.57% 增至32.73%,说明该方案在高并发场景下具有更加明显的优化效果。

  • 实验结果可视化

2并发

cc0e52ba649a2cde106bce9fe99ad1e8

4并发

fb1d610aa3c5de9371bbfa9e0f499301

8并发

348505a81d618700f2cec0d13062e71a

4.5 调度器实验结果分析

根据实验结果得出下面几个结论:

  1. 调度器有效

    两种调度器都有效降低了 V 状态保存次数和保存时间。在 2、4、8 并发下,V-hot 和 V-hot User-Kernel Cooperative Scheduling 相比 Baseline 都减少了 save count、save time 和 saves/s,说明 V 状态感知调度确实降低了 Dirty V 线程被频繁切出的概率。

  2. 动态PD阶段提示调度在高并发下优势更明显

    8 并发下,协同调度 save count 降低约 34.3%,save time 降低约 32.7%,TPS 提升约 12.4%,Mean TTFT 降低约 12.5%,P95 TTFT 降低约 10.9%。这说明阶段提示在高并发下能更好地区分 Prefill/Decode 工作特征,调度收益更稳定。

  3. 调度器不仅降低内核 V 状态开销,也能转化为应用层性能收益

    三种并发下,优化组 TPS 均高于 Baseline,Mean TTFT 和 P95 TTFT 基本低于 Baseline,说明减少 V 状态保存压力并不是孤立的内核指标改善,而是能反馈到大模型服务吞吐和首 token 延迟上。

4.6数据来源与复现要求

本项目统计数据来自:

analysis/phase_v2_effective_results_20260727.csv

为保证结果可比较,重复实验需要保持以下条件不变:

  • 使用相同的 BananaPi BPI-F3 真机及 RVV 1.0 环境;
  • CPU 频率策略固定为 performance governor;
  • 使用相同模型文件及量化版本;
  • 使用相同线程数、上下文长度、输入长度和最大输出长度;
  • 使用相同的 16 条不同内容、固定长度 Prompt;
  • 并发数与 llama-server parallel 参数保持一致;
  • Baseline 实验必须关闭 sched_ext 和主动阶段提示;
  • 优化实验必须保持相同的 proportional、adaptive sticky 和时间片参数;
  • 每组配置至少独立重复 3 次,并同时保留原始 JSON、运行日志和汇总 CSV。

5、特色创新点

​ 本项目在完成 V 状态开销量化和 sched_ext V 状态感知调度器的基础上,进一步完成了赛题可选完成项 c 和 d:其一,设计并实现 llama.cpp 到 BPF 调度器的用户态-内核阶段提示接口,使推理框架能够向调度器声明 Prefill/Decode/Mixed 计算阶段;其二,新增 x86 AVX2 横向对照实验模块,用定量数据分析 RVV VLA 设计在 V 状态切换问题上的特殊性。以下创新点按“真实状态观测—调度策略实现—可选项扩展—实验闭环验证”的逻辑展开。

5.1 基于架构 Dirty 状态的真实 V-hot 反馈

常见的 CPU affinity 方案只能静态限制线程运行位置,基于进程名或 CPU 利用率的策略也无法判断任务切出时是否需要保存向量寄存器。本项目在 sched_ext 的 stopping() 时点读取 RISC-V pt_regs.status.VS,将 VS=Dirty 转换为调度器可消费的 V-hot 信号。该信号来自体系结构状态,而非工作负载名称或外部经验规则,因此能够更准确地描述“本次切出是否可能产生 V 状态保存压力”。

5.2 sched_ext 中时间片、vtime 与 CPU 局部性的组合策略

静态 scx_vstate 调度器没有将“向量任务优先”实现为不可控的高优先级策略,而是组合三类机制:长时间片用于减少 Dirty runnable 线程的短周期抢占;原 CPU 空闲直派用于保持 cache/TLB 局部性;有界 vtime boost 用于提供有限优先级,同时通过共享 DSQ、原任务权重和睡眠积分上限维持公平性。三类机制分别作用于抢占频率、任务放置和服务份额,且参数可以独立调节,便于后续通过 boost=1、关闭 sticky 等配置进行消融分析。

5.3 可选完成项 c:用户态-内核阶段提示接口

针对“允许推理框架向调度器声明计算阶段”的可选要求,本项目没有停留在接口设想层面,而是在 dynamic_sched_ext/scx_vstate.h 中定义了固定 ABI、阶段枚举和 phase_hints map 路径。llama-server 通过 LLAMA_VSTATE_PHASE_HINTS=1 显式开启提示通道,并使用 Linux bpf() syscall 更新 pin 到 bpffs 的 BPF map。该接口包含 ABI 版本、阶段、更新时间戳、prefill/decode token 数和 batch token 数;BPF 侧对版本、TTL、阶段范围和 token 一致性进行校验,避免无效或过期提示进入调度决策。

5.4 可选完成项 c:动态 PD 提示调度器实现

动态 dynamic_sched_ext 调度器将可选完成项 c 的阶段提示进一步落实为调度策略。llama.cpp 在 batch 构造路径中为 prompt token 标记 Prefill,为采样 token 和 speculative draft token 标记 Decode,并在 llama_decode() 执行窗口内通过 RAII scope 写入阶段提示。BPF 调度器在 task_slice() 和 task_boost() 中消费该提示:Prefill、Decode 和 Mixed 阶段分别使用不同时间片与 vtime 权重,Mixed 阶段按照当前 batch 中 prefill/decode token 比例进行加权。由此,调度器不依赖线程名或 CPU 利用率推断阶段,而是使用推理框架内部真实 batch 语义进行动态调度。

5.5 可选完成项 d:x86 AVX2 横向对照与 RVV VLA 定量分析

针对“与 x86(AVX2/AVX-512)平台进行横向对比”的可选要求,本项目新增 x86_avx2/ 实验模块,复用同一套 llama.cpp server 压测口径,并采集 /proc 上下文切换、CPUID leaf 0xD、XCR0 与 XSAVE/xstate 状态大小。实测平台启用 AVX2,XSAVE area 为 832 B,其中 AVX2 的 AVX_YMM_Hi128 组件为 256 B;在 2、4、8 并发下,x86 对照数据给出了上下文切换/请求与估算 xstate 数据量随并发增长的趋势。结合 SpacemiT K1 的 VLEN=256,一次 RVV V 寄存器保存理论数据量为 1024 B,约为当前 AVX2 XSAVE area 的 1.23 倍;同时该数据量随 VLEN 线性增长。这一对照定量说明:x86 AVX2 的扩展状态大小在同一平台上基本固定,而 RVV 的 OS 保存规模与实现定义的 VLEN 绑定,体现了 VLA 设计在调度与上下文切换问题上的特殊性。

5.6 面向真实硬件评测的一体化实验闭环

项目覆盖 x86_64 WSL2 到 RISC-V 的 llama.cpp、内核模块、BPF skeleton、libbpf 加载器和两类 sched_ext 调度器交叉构建,并将服务健康检查、固定输入并发压测、内核观测、调度器日志、环境快照、均值/标准差计算和绘图统一到 run ID 目录。该闭环同时支撑 Baseline、静态 scx_vstate、动态 dynamic_sched_ext、profiler 和 x86 AVX2 对照实验,能够追溯每个结果对应的模型、内核、线程、调度器和阶段提示状态,降低系统软件实验中常见的配置漂移和手工记录误差。

6、项目地址及成员分工

项目地址:KiaraStark/bananaOS

成员分工:

成员1:系统观测与开销量化负责人:负责 RVV V-state 保存/恢复开销的观测方案设计与实现,包括内核埋点、debugfs/profiler 数据采集、实验日志整理,以及 V-state total ms、save/restore count 等指标统计。

成员2:调度优化与内核侧实现负责人:负责 V-hot 负载感知调度策略设计与实现,包括 sched_ext/scx_vstate 调度器接入、V-hot 线程识别、CPU 亲和性优化、时间片参数调优,以及不同并发场景下的策略对比。

成员3:LLM 推理实验与结果分析负责人:负责 llama.cpp 服务端部署、模型与并发实验配置、自动化测试脚本维护,以及 TPS、mean TTFT、P95 TTFT 等性能指标的统计分析、图表绘制和报告撰写。

7、参考

  1. RISC-V Vector Extension 1.0 Specification
  2. Linux Kernel:Vector Extension Support for RISC-V
  3. Linux Kernel:Extensible Scheduler Class
  4. llama.cpp:RISC-V RVV backend support
  5. llama.cpp PR #12530:add 128-bit RVV support
  6. V-Seek: Accelerating LLM Reasoning on Open-hardware Server-class RISC-V Platforms
  7. https://riscv-europe.org/summit/2024/media/proceedings/posters/42_poster.pdf
关于
253.1 MB
邀请码
    Gitlink(确实开源)
  • 加入我们
  • 官网邮箱:gitlink@ccf.org.cn
  • QQ群
  • QQ群
  • 公众号
  • 公众号

版权所有:中国计算机学会技术支持:开源发展技术委员会
京ICP备13000930号-9 京公网安备 11010802047560号