新闻详情

新闻详情

首页 / 资讯中心 / 详情

vllm源码解析:EngineCoreProc为何在run_engine_core才创建?多进程惰性初始化设计

发布时间:2026/10/1 17:16:10来源:尧图网络
vllm源码解析:EngineCoreProc为何在run_engine_core才创建?多进程惰性初始化设计
最近在啃 vllm 的源码看到EngineCoreProc的时候卡了很久。类本身不复杂可调用它的地方偏偏放在run_engine_core这个入口里也就是说只有当真正要启动引擎核心进程的时候EngineCoreProc对象才被 new 出来。我不禁想问为什么不能提前建好这个用法到底图什么带着这个疑问我把 vllm 的 executor、engine、multiprocess 相关代码翻了一遍又动手模拟了几个小实验现在可以很确定地告诉你这不是随手写的而是一套非常成熟的多进程惰性初始化设计。这篇文章就把我的理解掰开揉碎讲清楚顺便给同样在啃源码的你提个醒——看到类似的“入口函数里才创建进程对象”的写法别再第一反应是代码绕了。1. 源码里那行“奇怪”的创建代码到底藏了什么设计1.1 先还原一下代码现场很多朋友翻 vllm 源码时注意到的代码长得像这样def run_engine_core(engine_args, rank, world_size, ipc_socket, ...): core_proc EngineCoreProc( engine_argsengine_args, rankrank, world_sizeworld_size, ipc_socketipc_socket, ... ) core_proc.start() return core_proc这里有个必须澄清的细节run_engine_core并不是EngineCoreProc的成员方法而是一个独立模块级入口函数。它做的事情看起来只有三件拼装参数、构造进程对象、启动进程。为什么要套这么一层间接原因在于EngineCoreProc继承自multiprocessing.Process如果把“构造对象 start()”直接写到 Executor 的__init__里那所有初始化逻辑全部挤在父进程构造阶段后面会遇到一堆问题。所以我更愿意把run_engine_core理解成一个“进程启动器”而EngineCoreProc是它手里的工具什么时候用由启动器说了算。1.2 真正的“大头”在 run() 而不是init()EngineCoreProc的__init__非常“轻”通常只保存参数、设置进程名、配置退出事件。所有重活全部放在run()方法里class EngineCoreProc(mp.Process): def __init__(self, engine_args, rank, world_size, ...): super().__init__() self.engine_args engine_args self.rank rank self.world_size world_size # 这里不要初始化 CUDA不要创建模型实例 def run(self): # run_engine_core 里才会真正进入引擎初始化 run_engine_core( self.engine_args, rankself.rank, world_sizeself.world_size, ... )调用proc.start()后新进程会执行run()也就是在run()里再调用那个“模块级入口”。换句话说你在run_engine_core这个函数里看到EngineCoreProc(...)只是把一个准备就绪的流程“点着”真正的引擎对象、CUDA context、KV cache 全都在run()之后的新进程里完成。如果构造函数里就把模型加载了那父进程也会被拖下水白白吃一份显存而且 spawn 模式下还会因为对象无法序列化直接报错。2. 为什么不早不晚偏要等到 run_engine_core 才创建 EngineCoreProc2.1 参数到运行时才能确定这是最直观也最容易被忽视的原因。EngineCoreProc需要的信息里相当一部分不是 import 阶段就能拿到的分布式并行配置tensor_parallel_size、pipeline_parallel_size可能是从命令行传进来也可能是根据 GPU 数量自动检测出来的。vllm 支持--auto之类的逻辑只有真正解析完参数才知道最终要用几个 worker。设备顺序多卡机器上CUDA_VISIBLE_DEVICES是启动脚本设置的而 Python 进程 import vllm 的时候环境变量可能还没被正确设置。IPC 通信端点父进程要提前创建 socket listener、消息队列或者 pipe然后把地址传给子进程。这些对象的创建往往也依赖运行时条件比如端口是否被占用、队列容量多大。如果在模块加载阶段就执行EngineCoreProc(...)很多参数还是None或者占位值之后还得改对象属性不仅丑还容易出那种“改了 A 处忘了 B 处”的 bug。把创建动作延迟到run_engine_core所有参数都已经是最终值传进去就是干净的一锤子买卖。2.2 进程启动方式决定了不能提前“冻结”状态vllm 目前在多进程路径上很看重安全性很多场景会走spawn方式启动子进程。spawn和fork有本质区别fork会把父进程内存原样复制一份子进程能继承父进程里的几乎所有对象spawn则会重新导入主模块然后通过 pickle 把Process对象的参数传过去所以传给Process构造函数的参数必须能被正常序列化。假设你在 import 时就把EngineCoreProc建好并且里面存了一个已经加载出来的 model config 对象、一个 CUDA context 的引用那在 spawn 模式下这些大概率都无法 pickle子进程一起动就爆炸。就算你只用 fork提前创建的进程对象里也存了一些不必要的中间状态子进程 all copy 一遍白白增加内存消耗。run_engine_core函数内部构造则可以做到“现场准备”它拿到的参数是刚刚从配置里解析出来的 dataclass、字符串路径、整数等基本类型天然可 pickle构造前随时可以更新环境变量也不影响已经存在的外层对象。这种“用到再打包”的思路其实和写网络请求时只在发送前才构造请求体是一样的都是为了确保状态最新、最干净。2.3 生命周期管理用多少建多少坏了重建假设一个 Executor 需要管理 4 张卡那它最终会启动 4 个EngineCoreProc。如果把这些进程对象全部在 Executor 初始化时建好并启动显存瞬间就会被吃掉大半就算后续推理请求很少资源也白白占着。vllm 的做法是按需拉起先准备好参数等真正要跑推理了才调用run_engine_core把进程拉起来。更有价值的是故障恢复场景。多进程推理里偶尔某个子进程会因为 OOM 或者 CUDA error 挂掉。如果 Executor 手里只有一份描述进程怎么启动的“配置”挂掉后重新调一次run_engine_core就能再拉一个新进程相当于热重启了引擎核心而 Executor 本身不需要重建。这个能力在长驻服务里非常重要我处理线上故障时好几次靠这种机制避免了整个推理服务不可用。还有一种极端情况某些大模型加载期间会初始化进程级的内存池。如果提前创建多个进程每个进程都会在父进程里留下记录如果你用的是 fork父进程的内存布局会越来越复杂。延迟创建则让每个进程相对独立父进程始终只保存轻量级的元数据。3. 深入 EngineCoreProc 的实现函数、进程与状态的三角关系3.1 multiprocessing.Process 到底是怎样工作的要理解这个设计得先把multiprocessing.Process的工作机制捋一遍。我们平时写p mp.Process(targetmy_func, args(1, 2)) p.start()start()内部会做两件大事一是根据启动方式fork/spawn创建子进程二是在子进程里调用p.run()。默认的run()实现是def run(self): if self._target(): self._target(*self._args, **self._kwargs)所以 Process 对象本身只是个“壳”真正的动作在 target 函数里。EngineCoreProc的子类化思路就是重写run()把 target 换成一个可以访问实例属性的方法。这样你不仅能用self.engine_args拿参数还能在run()前后增加清理逻辑、日志逻辑、退出通知逻辑等。这比直接把函数传给Process(target...)要灵活得多。3.2 “函数里创建对象”就是工厂模式的变体run_engine_core函数内部构造EngineCoreProc本质上是一个典型的工厂函数。调用方只想说“帮我把引擎核心跑起来”并不关心具体是EngineCoreProc还是将来的某个EngineCoreThread。这种解耦带来几个直接的好处上层代码不直接依赖EngineCoreProc的构造函数后续想加参数只需要改工厂函数内部调用点不用动。可以统一处理参数校验、日志初始化、环境变量设置等横切逻辑。单元测试时你可以 mock 掉工厂函数的返回值直接返回一个假的进程对象从而测试 Executor 的行为。其实不只是 vllm很多分布式框架都喜欢用这种“入口函数 进程子类”的组合。比如换一个并发模型多线程、Ray actor、Celery worker只要重写工厂函数上层业务代码完全无感。3.3 一个容易被忽略的细节谁是“父进程”在run_engine_core里创建EngineCoreProc时当前所在的那个进程就是父进程。听起来像废话但仔细想就有意思了。Executor 的初始化可能发生在主线程也可能发生在asyncio事件循环线程里。如果把创建动作放在 Executor 初始化阶段那父进程自然是主进程可 vllm 的异步执行引擎往往需要把这些 worker 的创建绑定到特定的调用上下文里比如要拿到asyncio.get_running_loop()创建的 IPC listener。只有在run_engine_core这个入口函数里才能保证它所在的环境和后续子进程通信所需的事件循环、队列等资源处于同一个上下文。如果提前到一个没有 IPC listener 的地方创建那构造出来的 Process 对象里可能只存了一个不存在的 socket 地址子进程启动后就连不上父进程。这也是为什么很多同学自己写多进程代码时老遇到“子进程起来后不知道怎么跟父进程通信”的尴尬——创建得太早通信设施还没备好。4. 从调用链看 EngineCoreProc 的完整生命周期4.1 一步一图先看简化调用链我自己画过一个简化流程看完基本就明白整个生命周期了用户调用LLM或LLMEngine创建 Executor。Executor 根据并行策略算出需要几个 worker、每个 worker 的 rank 和 device。Executor 调用run_engine_core(engine_args, rank, world_size, ipc_socket, ...)。run_engine_core函数内部实例化EngineCoreProc并调用proc.start()。proc.start()创建子进程在子进程里执行EngineCoreProc.run()。run()解包属性再次调用内部真正的引擎初始化函数比如_run_engine_core。_run_engine_core加载模型、初始化 KV cache、启动 engine然后通知父进程“我准备好了”。之后父进程和子进程通过已经建立好的 IPC 通道进行请求转发和结果回收。这个链路里EngineCoreProc对象不再是“模型本身”它只是一个负责把模型启动起来的管理者。进程的生死、通信的建立都围绕着这个对象展开。4.2 为什么需要两层“run_engine_core”有朋友会问既然EngineCoreProc.run()里已经调用了一次run_engine_core那为什么外层还有一个run_engine_core入口这两者不重名吗这里要分清楚外层那个是“工厂函数”负责创建进程内层那个是“初始化函数”负责初始化引擎。在 vllm 的不同版本里命名可能有所差异但职责一定是分离的。我自己实现类似系统时也刻意保持这种两层结构外层函数只做事关进程生命周期的事比如设置进程属性、调用start()、记录日志内层函数只做事关引擎能力的事比如绑定设备、加载模型、启动事件循环。这样分层之后单元测试非常舒服——我可以单独测试内层函数而不用起真实进程也可以 mock 内层函数来测试外层逻辑。4.3 关键阶段与职责速查表阶段所在位置主要职责容易踩的坑参数准备Executor解析 tp/pp、rank、设备、日志路径环境变量设置得太晚导致 worker 用了错误的 GPU调用工厂入口外层run_engine_core构造EngineCoreProc并start()忘记传 IPC 地址子进程起不来子进程启动proc.start()fork/spawn执行run()spawn 模式下参数不可 pickle运行 run()EngineCoreProc.run()解包参数调用内部初始化在__init__里做了重活父进程也被拖累引擎初始化内部_run_engine_core加载模型、初始化显存池不同 rank 加载同一模型浪费显存就绪上报子进程逻辑通过 IPC 发送 ready 信号信号发送太早父进程还没准备好接收通信与请求事件循环 / Executor转发请求、回收结果队列对象被多处继承导致数据混乱这张表我每次排查问题都会拿出来对一遍基本能定位到具体环节。4.4 一个实际启动日志的观察如果你设置环境变量VLLM_LOGGING_LEVELDEBUG再启动一个多进程推理服务会看到类似这样的输出[rank0] EngineCoreProc: started engine core with pid 12345 [rank0] EngineCoreProc: engine core ready for requests这个日志出现的位置恰好验证了执行顺序先有run_engine_core创建对象并调用start()再有run()里的引擎初始化。我在本地实验时还在工厂函数里临时加了一句print(factory called in pid, os.getpid())在run()里也加了一句两次打印的 PID 完全不同。这一个简单的实验就能让“对象在父进程创建、逻辑在子进程运行”的概念变得非常直观。5. 这种“延迟Create 进程包装”模式还能用在哪些场景5.1 同类场景分布式推理、Worker 池、Actor 模型这种模式并不只属于 vllm。在 Ray 里ray.remote的 actor 也是“定义时只是描述实例化时才真正创建进程”在 Celery 或 RQ 里worker 进程也是按需拉起而不是在 import 时全部启动。底层逻辑都一样进程/计算资源昂贵必须等到真正要运行时才去创建创建前还要保证所有参数都是新鲜的。我自己做过多卡模型并行推理时就借鉴了 vllm 的写法。一开始我在__init__里把 8 个 worker 进程全部start()结果启动时间 3 分钟显存占用直接爆表。改成延迟创建后先注册“怎么创建”的配置等到推理请求进来时才逐个拉进程整个过程优雅多了。5.2 自己实现时如何借鉴如果你也要实现一个多进程推理引擎可以直接套用这个模板def start_engine_worker(engine_config, rank, world_size): proc EngineWorkerProcess( engine_configengine_config, rankrank, world_sizeworld_size, ) proc.start() return proc class EngineWorkerProcess(mp.Process): def __init__(self, engine_config, rank, world_size): super().__init__(namefengine-worker-{rank}) self.engine_config engine_config self.rank rank self.world_size world_size self.ready_event mp.Event() def run(self): # 设置进程对应的 GPU local_rank self.rank % torch.cuda.device_count() os.environ[CUDA_VISIBLE_DEVICES] str(local_rank) engine create_engine(self.engine_config) engine.start() self.ready_event.set() engine.run_forever()关键点在于engine_config必须是可 pickle 的 dataclass不能是已经持有模型对象的引用。ready_event可以用multiprocessing.Event通过进程继承传给子进程这样父进程可以wait()等 worker 就绪。这其实就是 vllm 多进程通信的原型。5.3 什么情况下不建议这种用法当然不是所有场景都适合延迟创建。如果进程对象成本极低、数量固定并且所有参数在 import 时就能确定那提前创建可以简化代码。比如一个固定的线程池提前创建反而更好。EngineCoreProc之所以必须延迟是因为它背后是几十 GB 显存、CUDA context 和通信组这种重量级资源没法儿像轻量对象一样随便存着备用。另外如果你的业务要求进程必须提前预热减少首个请求的延迟那可以折中在服务启动后的一个专门“预热阶段”调用工厂函数而不是真的等到首个推理请求。这样既享受了参数延迟确定的优势又不牺牲冷启动时间。6. 踩坑实录与排查技巧EngineCoreProc 相关问题的快速定位6.1 常见问题速查表报错信息可能原因解决办法AttributeError: EngineCoreProc object has no attribute xxx工厂函数里构造对象时少传了参数或__init__里忘记保存属性检查run_engine_core的调用点参数列表逐一核对__init__Daemonic processes are not allowed to have children在 daemon 进程里又创建了Process子进程检查父进程是否设置了daemonTruevllm 的 worker 不要设 daemonRuntimeError: Queue objects should only be shared between processes through inheritance在 spawn 模式下把Queue作为普通参数传给了子进程用继承方式fork或改用multiprocessing.Manager/PipePicklingError: Cant pickle ...传给 EngineCoreProc 的参数里有不可序列化对象比如模型实例、引擎对象只传配置 dataclass、路径、数值把复杂对象限制在run()内部创建启动后没有任何日志进程挂住子进程等待某个 IPC ready 信号而父进程还没发送检查 ready 事件/信号是否在引擎初始化完成后才 set不要提前发多卡显存分配不均匀CUDA_VISIBLE_DEVICES在所有 worker 里被设成了同一个值在run()里根据rank重新设置设备号再用torch.cuda.set_device子进程崩溃后 Executor 无法恢复没有捕获子进程异常也没有重新调用工厂函数在 Executor 里做进程存活监控崩溃时重新调用run_engine_core这些坑我基本都踩过一遍。最典型的是“在__init__里加载模型”看起来没什么问题但在 fork 模式下会复制两份模型权重在 spawn 模式下直接序列化失败。后来看到 vllm 把重活全放run()我才彻底改掉这毛病。6.2 排查技巧给 run_engine_core 加临时日志定位这类问题最有效的笨办法就是加日志。在工厂函数入口加def run_engine_core(...): logger.info(Enter run_engine_core, pid%s, os.getpid()) proc EngineCoreProc(...) logger.info(EngineCoreProc object created, pid%s, os.getpid()) proc.start()在EngineCoreProc.run()里加def run(self): logger.info(Enter EngineCoreProc.run, pid%s, os.getpid()) ...正常情况下你会看到第一条日志的 PID 和第三条日志的 PID 相同都是父进程第二条日志的 PID 是新的子进程 PID。如果子进程 PID 没有出现说明start()根本没成功如果第一、第二条都出现了但第三条没有说明run()里在创建引擎之前就崩了。这种“从日志找 PID 分叉点”的方法能快速把问题范围缩小到进程启动层还是引擎初始化层。6.3 一个独家心得进程复用 vs 每次重建很多服务会频繁做 model reset 或者 weight update。如果每次都杀掉EngineCoreProc再重新start()进程的 fork/spawn 开销其实不小尤其是当父进程内存已经很大时spawn 的方式要重新导入主模块费时费力。我个人的做法是保留EngineCoreProc对象但只重启它内部的 engine 实例。vllm 里并不是所有场景都这么做但如果你自己掌控了run()的逻辑可以设计成“收到重置命令 - 释放旧引擎 - 加载新权重”的模式。这样EngineCoreProc进程本身不退出IPC 通道也不断省掉了通信重建的成本。当然如果模型结构变化很大、显存碎片严重那还是整个进程重建更干净。这里我的经验是“先试进程内重置如果显存没有回落再走重建”实测下来能把服务抖动时间从几十秒降到几秒。之前我还一直觉得这种“入口函数里才创建进程对象”的写法很绕直到有一次线上显存报警我检查后发现罪魁祸首就是提前创建的进程对象带着父进程里的 CUDA context 被 fork 了一地。后来照着 vllm 的风格把进程类的构造挪到统一的start_engine_worker函数里问题直接消失。以后再看到类似的代码先别急着吐槽绕想想它背后省掉的显存、规避掉的 pickle 坑、以及热重启带来的灵活性你就明白这个“延迟”到底值不值。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

静态路由综合实验全攻略:配置、回程路由与排障实战 2026/10/1 17:50:32

静态路由综合实验全攻略:配置、回程路由与排障实战

工作中经常碰到一种情况:学网络的朋友在eNSP或者GNS3里搭了个多路由器的拓扑,然后照着教程一条一条配静态路由,最后ping不通,就开始怀疑设备、怀疑拓扑、怀疑人生。其实大部分问题都出在“只配了去程,没配回程”&#…

阅读更多 →
AI限速治理:从协议、芯片到模型的闭环实践 2026/10/1 17:50:25

AI限速治理:从协议、芯片到模型的闭环实践

1. 这不是新闻简报,而是一份AI治理现场观察手记今天早上七点四十三分,我盯着安理会听证会直播页面上那个被反复打码的“AI限速”提案PDF封面,手指悬在键盘上方停了三秒——这标题里没一个字是虚的,但每个词都像裹着三层雾。云栖、…

阅读更多 →
腾讯位置服务热力图实战:坐标聚合、分位数与性能调优 2026/10/1 17:50:25

腾讯位置服务热力图实战:坐标聚合、分位数与性能调优

做地图可视化的人大概率都遇到过这种场景:业务方丢过来一张几十万行的设备上报记录或者订单表,就问一句"能不能看出人都在哪儿扎堆"。绕来绕去,你最终要交付的核心其实就是一张读得懂的热力图。腾讯位置服务在这件事上给了一套相对…

阅读更多 →
Seata连接Nacos认证失败403:特殊字符URL编码问题解析 2026/10/1 17:50:19

Seata连接Nacos认证失败403:特殊字符URL编码问题解析

1. 问题本质与真实场景还原Nacos 和 Seata 在微服务架构中属于高频共存组件:Nacos 作为注册中心和配置中心,Seata 作为分布式事务协调器,两者通过registry.conf配置文件建立连接。但当 Nacos 启用了账号密码认证(尤其是密码含特殊…

阅读更多 →
AI日报制作全流程:从信息筛选到技术拆解与知识管理 2026/10/1 17:50:18

AI日报制作全流程:从信息筛选到技术拆解与知识管理

1. 一份AI日报的诞生:从信息洪流到结构化简报每天早上七点,我的浏览器标签页会同时打开十几个信息源:arXiv上的最新预印本、几个头部AI实验室的官方博客、GitHub Trending、还有三四个行业社群的讨论串。这个习惯保持了快三年,起因…

阅读更多 →
阿里云ECS磁盘使用率过高排查:定位、清理与在线扩容实战 2026/10/1 17:50:12

阿里云ECS磁盘使用率过高排查:定位、清理与在线扩容实战

运维干了几年,最怕半夜收到阿里云的短信告警,其中磁盘使用率超过80%这条尤其让人头疼。很多新手同学第一反应是直接扩容,结果扩完没两天又满了,其实核心问题是没搞明白数据到底是谁占的。这篇文章就把我处理阿里云ECS磁盘使用率过…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉