☰
02 · 架构边界:CUDA 为何是独立运行期模块
2026/10/8 14:58:03 网站建设 项目流程

02 · 架构边界:CUDA 为何是独立运行期模块

一句话:引擎不链接CUDA——它把 CUDA 层编成一个独立动态库,运行期按固定顺序去找、去绑;找到且可用就走 GPU,找不到就让所有入口变成无害桩,行为与引入前逐字节一致。这么做不是风格偏好,而是 Windows 上 nvcc 与引擎的两套 C 运行期根本链接不到一起。
前置:建议先读 第 01 篇 · 阶段总览。
环境:x86-64 + NVIDIA RTX 5090(sm_120)· 引擎用 MinGW/GCC 构建,CUDA 层用 nvcc/MSVC 构建。

一、问题与结论

目标只有一句:有 GPU 时用 GPU,没有时行为不变。这个目标决定了边界必须是「运行期可选的独立模块」,而不是「链接期依赖」。

目标落地做法判定
有 GPU 就用 GPU运行期加载vllm_cuda.dll/libvllm_cuda.so实测可用
无 GPU 逐字节零回归未编入 / 模块缺失时所有入口退化成无害桩实测一致(见 第 01 篇 阶段一)
版本错配不「半绑定」任一入口符号缺失即整体放弃(missing = 1)设计约束
能分离「初始化副作用」与「输出被真正使用」VLLM_CUDA_FORCE_CPU逃生阀实测(见第四节)

二、背景:为什么不直接链接

这不是风格问题,是 Windows 上的硬约束。

约束引擎侧CUDA 侧
编译器MinGW-w64 GCCnvcc(Windows 主机编译器只有 MSVC)
依赖头pthread.h/unistd.h/__int128MSVC 工具链
C 运行期MinGW CRTMSVC CRT(_Init_thread_*、ucrt)

两套 CRT 不能链接到一起。把 MSVC 编译的 CUDA 目标文件塞进 MinGW 二进制,会拖入_Init_thread_*/ucrt,链接期直接失败。纯 C 的模块边界让两边各用各的 CRT,互不干扰。

这与 NPU 后端的做法同源:librknnrt也是运行期动态加载的树外运行时。

CMakeLists.txt的注释把这条写死了:CUDA 后端默认OFF,只有 x86-64 宿主 + 独立 GPU 才允许ON,aarch64(RK3588) 必须保持OFF。

# 文件:CMakeLists.txt(节选) # 可选 NVIDIA CUDA 加速后端(默认 OFF) # nvcc 在 Windows 上必须用 MSVC 做宿主编译器,而本引擎是 MinGW/GCC 构建 if(VLLM_CUDA) if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64|ARM64") message(FATAL_ERROR "VLLM_CUDA targets an x86-64 host with a discrete GPU; " "keep VLLM_CUDA=OFF on aarch64 (RK3588)") endif() ... add_compile_definitions(VLLM_HAVE_CUDA=1) # 启用运行期加载器 endif()

三、核心机制

3.1 运行期加载与模块发现顺序

引擎启动时按固定顺序去找这个模块:

1. $VLLM_CUDA_LIB (显式路径,调试/多版本用) 2. 可执行文件同目录 vllm_cuda.dll / libvllm_cuda.dll 3. 平台加载器搜索路径

前两条落在vc_module_open()里:先看环境变量,再找可执行文件所在目录。第 2 条用vc_exe_dir()求目录——Windows 走GetModuleFileNameA,Linux 走readlink("/proc/self/exe")。

/* 文件:src/npu/cuda/vllm_cuda.c(vc_module_open,节选) */staticvc_lib_tvc_module_open(char*found,size_tcap){#ifdefined(_WIN32)constchar*names[2]={"vllm_cuda.dll","libvllm_cuda.dll"};#elseconstchar*names[2]={"libvllm_cuda.so","vllm_cuda.so"};#endifconstchar*env=getenv("VLLM_CUDA_LIB");if(env&&env[0]){/* ① 显式路径 */vc_lib_th=vc_lib_open(env);if(h){if(found)snprintf(found,cap,"%s",env);returnh;}}chardir[1024];if(vc_exe_dir(dir,sizeof(dir))==0){/* ② 可执行文件同目录 */for(inti=0;i<2;i++){charp[1200];snprintf(p,sizeof(p),"%s/%s",dir,names[i]);vc_lib_th=vc_lib_open(p);if(h){if(found)snprintf(found,cap,"%s",p);returnh;}}}for(inti=0;i<2;i++){/* ③ 平台搜索路径 */vc_lib_th=vc_lib_open(names[i]);if(h){if(found)snprintf(found,cap,"%s",names[i]);returnh;}}returnNULL;}

模块缺失是正常状态,不是错误。vllm_cuda_init()找不到模块时写一条 note(module not found ...; CPU path)然后照常返回 0(成功但不可用)。

3.2 入口点绑定与版本校验

模块打开后,逐个dlsym/GetProcAddress把vcuda_api_t里的函数指针绑上:

/* 文件:src/npu/cuda/vllm_cuda.c(vllm_cuda_init,节选) */intmissing=0;#defineVC_BIND(field,sym)\do{*(void**)(&c->api.field)=vc_lib_sym(c->lib,sym);\if(!c->api.field)missing=1;}while(0)VC_BIND(dev_create,"vcuda_dev_create");VC_BIND(dev_gemm_gw,"vcuda_dev_gemm_gw");VC_BIND(dev_gemm_gw_f32,"vcuda_dev_gemm_gw_f32");VC_BIND(dev_wcache_window,"vcuda_dev_wcache_window");VC_BIND(dev_decode_run,"vcuda_dev_decode_run");VC_BIND(dev_moe_preload,"vcuda_dev_moe_preload");…

任一入口缺失即整体放弃(missing = 1)——这是版本不匹配的护栏。DLL 与 exe 版本错配时,宁可不加速,也不能半绑定运行导致未定义行为。

这里还有一个关库的坑值得记一笔:vllm_cuda_destroy()会无条件调用c->api.dev_destroy。如果某个失败分支只关了库却没清空c->api.*,这些指针就指向已卸载的模块,再一调就是访问违例(实测0xC0000005)。所以关库前先用vc_api_clear()把整张函数表清零。

是

否

是

否

是

否

是 missing=1

否

vllm_cuda_init 启动

① VLLM_CUDA_LIB 命中

VC_BIND 逐个绑定入口

② exe 同目录 dll 命中

③ 平台搜索路径 命中

无害桩 返回 0 走 CPU

任一符号缺失

整体放弃 不半绑定

available()==1 走 GPU

图 1:模块发现 → 绑定 → 降级的全有或全无链。vc_module_open()依次找$VLLM_CUDA_LIB、可执行文件同目录的vllm_cuda.dll、平台搜索路径;找到后VC_BIND逐个dlsym/GetProcAddress绑定入口,任一符号缺失即missing = 1整体放弃(绝不半绑定);找不到模块或绑定失败都退化成无害桩并返回 0,只有available()==1才走 GPU。

3.3 透明降级契约

vllm_cuda_init()永不失败调用方。设备不可用的所有情况(无模块 / 无 GPU / 版本错配 / 显存不足)统一通过vllm_cuda_available() == 0表达,且每个 offload 入口返回 0:

0 = 调用方必须自己跑 CPU 路径;1 = 已在 GPU 上完成。

这条约定被后端所有层次严格遵守。最底层的设备层用的是相反约定(0 = 成功),翻译发生在宿主层——例如vllm_cuda_gemm_gw():

/* 文件:src/npu/cuda/vllm_cuda.c(vllm_cuda_gemm_gw,节选) */if(!c||!c->ok||!c->api.dev_gemm_gw)return0;if(cuda_force_cpu())return0;…if(2.0*(double)M*(double)N*(double)K<c->flops_threshold)return0;intrc=c->api.dev_gemm_gw(c->dev,out,Aq,a_scale,Wq,b_scale,M,N,K,G,prec,wkey);return(rc==0)?1:0;/* 设备层 0=成功 → 宿主层 1=已加速 */

对外接口的这一契约在公共头文件里写得很直白(include/npu/cuda/vllm_cuda.h):

Returns 1 if executed on the GPU (out is filled), 0 if the caller must run the CPU path (no device / below FLOPs threshold / unsupported shape / any CUDA error).

而且「部分失败」永远不让out处于未定义状态——返回 0,调用方按 CPU 内核把每个输出重算一遍。

3.4 诊断逃生阀

调试「结果不对」时,最难分离的是两类原因:初始化的副作用vsGPU 输出被真正使用。为此留了VLLM_CUDA_FORCE_CPU:

/* 文件:src/npu/cuda/vllm_cuda.c(cuda_force_cpu,节选) *//* Diagnostic escape hatch: VLLM_CUDA_FORCE_CPU=1 keeps the device initialized * but makes every offload fall back to the CPU - separates "init side effect" * from "GPU output used" as the source of a wrong result. */staticintcuda_force_cpu(void){constchar*e=getenv("VLLM_CUDA_FORCE_CPU");return(e&&e[0]&&e[0]!='0');}

设备保持初始化(显存分配、上传都照做),但每个 offload 直接返回 0 走 CPU。

于是:

配置结果结论
VLLM_CUDA_FORCE_CPU=1输出正确初始化无副作用问题在 GPU 计算/数值
VLLM_CUDA_FORCE_CPU=1输出也错初始化有副作用问题在状态污染

3.5 编译开关与无害桩

-DVLLM_HAVE_CUDA决定引入的是真实加载器还是无害桩:

/* 文件:src/npu/cuda/vllm_cuda.c(节选) */#else/* !VLLM_HAVE_CUDA *//* Harmless stubs: the engine runs exactly as before this backend existed. */structvllm_cuda_s{intunused;};intvllm_cuda_init(vllm_cuda_t**out,constvllm_cuda_cfg_t*cfg){(void)cfg;if(out)*out=NULL;return0;/* success but unavailable: transparent fallback */}intvllm_cuda_available(constvllm_cuda_t*c){(void)c;return0;}

未定义该宏(VLLM_CUDA=OFF)、aarch64/RK3588 目标、或模块缺失时,走同一套桩。构建侧由tools/build/build_x64.ps1 -Cuda先产出vllm_cuda.dll,再以-DVLLM_HAVE_CUDA编入引擎:

# 文件:tools/build/build_x64.ps1(节选)if($Cuda){$nvcc=Get-Commandnvcc-ErrorAction SilentlyContinue...&$nvcc.Source-O2"-arch=$CudaArch"-cudart static-shared-fmad=false @cudaD `-Iinclude/npu/cuda src/npu/cuda/vllm_cuda_kernels.cu-o$dll...$cudaDefs= @('-DVLLM_HAVE_CUDA')}

-arch默认native;-fmad=false是为了不让编译器把乘加合并掉,保住数值可复现。

四、实测数据

VLLM_CUDA_FORCE_CPU的判定表(口径:同一次运行,只切这一个开关):

配置输出结论标注
VLLM_CUDA_FORCE_CPU=1正确初始化无副作用,问题在 GPU 计算/数值实测
VLLM_CUDA_FORCE_CPU=1也错初始化有副作用,问题在状态污染实测

第 10 篇 那个指针越界 bug 就是靠这个开关把范围从「所有 CUDA 代码」收窄到「初始化路径」的。

五、边界与已知限制

  • 这套边界只覆盖x86-64 宿主 + 独立 NVIDIA GPU;aarch64(RK3588) 目标必须VLLM_CUDA=OFF。
  • -Cuda构建需要在「x64 Native Tools Command Prompt for VS」里跑(nvcc 要cl.exe做宿主编译器)。
  • 出厂档与诊断档的 DLL 同名(都叫vllm_cuda.dll),一旦互相覆盖,别人拿到的可能是「名为出厂、实为诊断」的产物——所以诊断用的额外-D默认不允许写进出厂目录。
  • 「模块缺失 → 逐字节等价」是本篇边界能成立的前提,但它只保证未引入后端时行为不变;不能外推为「任何版本错配都安全」——版本错配走的是「整体放弃」而不是「部分可用」。

CPU 对照(迁移前基线)

  • CPU 参考:kestrel-llm裸引擎(无 CUDA 时每个 offload 由引擎自跑 CPU,桩行为与引入后逐字节等价)。
  • 迁移要点:CPU 侧单进程引擎 → CUDA 侧做成纯 C ABI 的独立运行期模块(MinGW 与 MSVC 两套 CRT 不可链接);任一入口缺失即整体放弃,VLLM_CUDA_FORCE_CPU作逃生阀;编译加-fmad=false。
  • 真机验证:命中 E1+E2(-Cuda构建成功、模块加载/VC_BIND 生效、selftest 全绿)。

六、小结(可复用结论)

  1. 硬约束决定架构:Windows 上 nvcc 只有 MSVC 宿主、引擎是 MinGW/GCC,两套 CRT 不可链接;因此 CUDA 层必须是纯 C ABI 的独立运行期模块,而不是链接期依赖。
  2. 「任一入口缺失即整体放弃」是护栏:宁可完全不加速,也不允许半绑定运行——后者是未定义行为的源头。
  3. init 永不失败、offload 用 0/1 表达:「有 GPU 加速、无 GPU 零回归」这条一致性契约,靠统一返回约定贯彻到每一层。
  4. VLLM_CUDA_FORCE_CPU是把两类原因分开的最小工具:设备照常初始化,只是每个 offload 走 CPU;一次切换即可判断错在初始化还是错在计算。
  5. 模块缺失是正常状态:加载器找不到模块不是错误,写个 note 就返回,引擎照常跑 CPU。

相关篇目:第 01 篇 · 阶段总览、第 03 篇 · 权重条带缓存与融合权重
源码与配套资源:本仓库 https://gitee.com/pei-xiaoguang/kestrel-llm-cuda.git;
CPU 推理源码 https://gitee.com/pei-xiaoguang/kestrel-llm

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

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

立即咨询