☰
Ray分布式计算框架实战:从单机并行到集群调度
2026/10/10 6:42:46 网站建设 项目流程

我最早折腾Ray,是因为一个非常现实的痛点:本地跑超参搜索,一组40个模型训练任务,单机要跑快两小时,数据稍微大点还经常内存爆掉。一开始我用的是multiprocessing,换到16核机器确实快了,但想扩到三台机器的时候直接无从下手——进程池根本不会跨节点调度。后来试过Celery,排队那一套做异步任务还行,但任务之间有依赖关系时,代码会变得特别绕。真正把这个问题解决的,是Ray这套Python分布式计算框架。它让我可以用几乎不改变写普通Python脚本的方式,把任务摊到几十台机器上。这篇文章就从一个实际使用者的角度,聊聊Ray的核心抽象、常见坑位和几个典型的应用场景,适合正在做Python并行化、ML训练、数据处理,或者想把单机脚本平滑扩展到集群的朋友。

1. 从单机到集群:为什么Ray的抽象方式决定了它的上限

1.1 先看看分布式框架都在解决什么问题

分布式计算本质上就三件事:调度、数据传输、状态管理。调度是把任务分给哪台机器执行,数据传输是任务之间的结果怎么高效传递,状态管理是那些"有状态"的组件(比如参数服务器、计数器、模型副本)如何保持一致。

很多框架的差异,其实就是在回答"这三件事你愿意付出多少心智负担"。

  • multiprocessing:单机调度,数据在进程间复制,通过Queue或者Manager传递。代码简单,但出了单机就废了。
  • Celery:任务队列模型,调度中心决定谁干活,结果存Redis/RabbitMQ,灵活性不错,但复杂的任务依赖需要手动拼接任务链。
  • Dask:调度器加worker池,提供了类似Pandas的集合式API,做数组和DataFrame计算很顺手,但自定义任务图的灵活度不如Ray。
  • Spark:RDD和数据框模型,适合大规模批处理和SQL,但任务粒度粗、启动开销大,互动式开发和细粒度任务调度体验一般。

我自己的判断标准很简单:如果我能继续像写单机Python一样描述业务流程,只是把"这里该并行"告诉框架,那就够了。Ray的独特之处在于,它把这层心思收了,让你直接用普通Python函数和类去描述分布式计算。你写一个函数加个装饰器,它就变成了能在任意节点上运行的远程任务;你写一个类加个装饰器,它就变成了一个有状态、可被多节点共享的Actor。

1.2 为什么"动态任务图"比"任务队列"更贴合真实业务

做分布式最头疼的一类场景是:任务的依赖关系不是固定的线性队列,而是运行过程中才产生的动态图。比如训练一个模型要经过数据加载、预处理、采样、训练、验证这几步,其中数据加载完成后下一步才能启动,但验证时又要等训练完成。如果用队列模型,你得给每一步命名、手工声明依赖;如果用Spark,你得把每一步压成RDD算子,别扭。

Ray使用的是动态任务图(DAG)。你调用一个远程函数,Ray就在运行时生成一个任务节点;你把这个节点的结果再传给下一个远程函数,Ray自动建立数据依赖。整个图是边跑边长的,依赖关系自然地从Python函数的调用关系中体现出来。

比如下面这段代码,看起来和普通Python没区别,但删掉.remote和ray.get的包装后,它就是一段标准Python逻辑:

import ray ray.init() @ray.remote def load_data(url): # 模拟读取大文件 return [{"id": i, "value": i * 2} for i in range(100000)] @ray.remote def preprocess(data): return [row for row in data if row["value"] % 3 == 0] @ray.remote def train(clean_rows): total = sum(row["value"] for row in clean_rows) return total / len(clean_rows) # 动态任务图:load_data -> preprocess -> train,依赖自动建立 raw = load_data.remote("s3://fake-bucket/train.csv") clean = preprocess.remote(raw) result = train.remote(clean) print(ray.get(result))

这段代码背后,Ray建立了一条完整的数据流链路:raw不是实际的数据,而是一个ObjectRef(对象的引用句柄);preprocess.remote(raw)是告诉调度器"任务preprocess需要依赖raw这个对象",等load_data跑完后会自动把raw对象传给preprocess所在的worker。你不需要为依赖建队列、起Celery的chain,调度逻辑和业务逻辑完全一致。

1.3 Ray架构里的两个关键角色

要用好Ray,至少要知道两个底层的组件:

  • GCS(Global Control Store):Ray的控制面,相当于一个轻量级的"注册中心+元数据库",存放对象元数据、actor地址、任务状态。所有worker启动时都会连接它。它的单点风险由Ray官方做了高可用方案,内部其实多个副本,用户可以不用过分担心。
  • Raylet:每个节点上都会运行的代理进程。它有两个职责:一是管理本节点的资源(CPU、GPU、内存、自定义资源),二是同时在节点上执行任务的调度与分配。任务提交时,调度器根据资源占用情况决定把任务发给哪个节点的raylet,raylet再在自己的进程池里执行。

对使用者来说,GCS和raylet的存在意味着两件事:第一,节点间通信和数据传递被框架接管了,你不需要自己开socket;第二,加一个worker节点本质上就是多跑一个raylet进程,扩容就是"开机→ray start"两件事,这也是Ray"易扩展"的底子。

2. 三个核心概念吃透Ray:remote函数、Actor和ObjectRef

2.1 Task:把普通函数变成分布式任务

Ray最基础的抽象是任务(Task),也就是被@ray.remote装饰的普通函数。调用方式从func(args)变成func.remote(args)。注意,这里有个新手最容易忽略的点:调用remote方法后,任务不会立刻执行并返回结果,而是立即返回一个ObjectRef。

ObjectRef可以理解成"异步结果的签收单"。任务在Ray集群中排队、调度、执行的时候,你的Python程序拿着签收单先干别的事情,真正需要结果时才用ray.get(obj_ref)去拿。

import time import ray ray.init() @ray.remote def slow_square(x): time.sleep(2) return x * x start = time.time() refs = [slow_square.remote(i) for i in range(8)] # 此时8个任务已经在多个进程中并行了,主进程没有被阻塞 results = ray.get(refs) # 一次性阻塞等待所有结果 print(f"总耗时: {time.time() - start:.2f}s") print(results)

如果这里写的是ray.get(slow_square.remote(i)),那每一轮都要阻塞等结果,实际退化成串行,速度会非常难看。正确姿势是先把remote调用全部发出去,拿到一堆ObjectRef,再一次ray.get批量拉取结果。这个"批量收集、统一获取"的习惯应该从一开始就养成。

如果任务列表特别长,比如几千上万个,全部结果一次性收集可能会撑爆内存。这时就要用ray.wait(refs, num_returns=4)一点一点取:先等前4个完成,取走结果,再等下一批。这在流式请求、边算边出的场景特别实用。

2.2 ObjectRef:连接任务间的数据流

ObjectRef有两个重要特性:只读和去重。

  • 只读:一旦对象创建,内容不可变。修改一个对象意味着要产生新对象,旧引用指向的旧对象会被垃圾回收。这让Ray不需要处理分布式场景下的锁竞争,数据在任务间传递时非常安全。
  • 去重:同一个ObjectRef在多个任务引用时,Ray会尽量只保留一份数据在共享内存中,通过引用计数管理生命周期。多个任务同时依赖同一份数据时,不需要复制多份。

小技巧:如果你有大数据要分发给多个任务,比如几十个worker都要用同一个词典做映射,不必在每次调用里都传递一份,那会把对象复制几十次。应该先用ray.put(big_dict)把对象放进分布式对象存储,拿到ObjectRef,然后把这个Ref传给所有任务,任务内部再ray.get(ref)读取。这样数据只有一份,所有人都共享,效率高很多:

big_dict = {i: i * 10 for i in range(1000000)} big_ref = ray.put(big_dict) # 数据放进分布式内存 @ray.remote def use_dict(ref, key): d = ray.get(ref) return d[key] refs = [use_dict.remote(big_ref, i) for i in range(100)] print(ray.get(refs)[:10])

2.3 Actor:有状态的计算单元

纯任务(Task)是无状态的:同一个函数跑两次,结果是独立的。但分布式场景经常需要"有状态"的东西——计数器、模型参数、数据库连接池、训练中的参数服务器。Ray用Actor解决这个问题。

Actor就是加了@ray.remote装饰器的类。实例化不再是MyClass(),而是MyClass.remote(),实例方法调用也统一变成`method.remote()``。

@ray.remote class Counter: def __init__(self): self.n = 0 def add(self, step=1): self.n += step return self.n counter = Counter.remote() # 连续调用同一Actor,状态会保留 print(ray.get(counter.add.remote())) print(ray.get(counter.add.remote(5))) print(ray.get(counter.add.remote()))

关键点:同一个Actor实例的方法调用在底层是串行执行的,因为那个实例的self.n只有一个,你必须保证状态的读写顺序。Actor可以拥有独立的CPU、GPU资源,也可以被多个Driver共享(比如一个Actor被多个训练任务同时访问,当作共享状态服务器)。

2.4 什么时候用Actor,什么时候用纯Task

我自己判断的标准就是一句话:没有共享状态,用Task;有共享状态,用Actor。

  • 对一批文件做格式转换,每个文件独立处理 → Task。
  • 分布式爬虫需要维护URL队列、去重集合 → Actor,用一个QueueActor管理待抓取URL,多个Task抓取后向Actor汇报。
  • 多个训练worker需要共享一份最新的模型权重 → Actor,比如ParameterServerActor负责维护参数,worker们定期拉取或推送更新。
  • 一次性的暴力计算、蒙特卡洛采样、并行搜索 → Task,跑完就结束,不需要保活状态。

另外一个经验是:Actor因为保活,会一直占着资源,不需要时要显式ray.kill(actor),否则它会一直挂在集群里。而Task跑完就释放资源,内存管理也简单。

3. 跑通第一个Ray程序之后,最容易踩的五个坑

3.1 序列化失败:闭包、自定义类和lambda

Ray要把函数、参数、返回值在不同进程间传递,就必须序列化对象。Ray内置了cloudpickle,比标准pickle强多了,闭包和大部分自定义类都能处理。但依然有三个高频雷区。

  • 模块级临时变量:比如你在一个Jupyter Notebook里定义了helper_func,又在另一个cell里@ray.remote引用了它。Notebook环境本身不是一个可导入的Python模块,Ray序列化函数时可能找不到定义,报错ModuleNotFoundError。
  • lambda表达式:@ray.remote(lambda x: x * 2)是不允许的,因为lambda没名字,Ray无法注册。
  • 自定义类的实例方法里引用了不可序列化对象:比如一个持有threading.Lock或者文件句柄的对象,就算cloudpickle也没辙。

排查链路通常是:先看到顶层报错ray.exceptions.RayTaskError,感觉很模糊;然后看串底部的UnserializableObjectError,才意识到是对象传不出去。此时建议写一个最小化复现脚本,逐个字段删减,定位到是哪个属性卡住。

解法:把大的不可序列化对象放ray.put到分布式内存,只传ObjectRef;把Notebook代码重构成可导入的.py模块;避免用lambda定义远程任务,改成普通函数。

3.2 在循环里调用ray.get:性能杀手

新手最常见的写法bug:

for i in range(1000): result = ray.get(fetch_data.remote(i)) process(result)

这段代码每轮都等一个任务完成才开始下一轮,等于把并行又改回了串行。更糟的是,循环里的ray.get反复阻塞主线程,调度器也没法做全局优化。

正确做法是两种:

  • 收集全部ObjectRef,再一次ray.get。
  • 如果结果太大不能全收,就用ray.wait配合生产者-消费者模式,边算边收。
pending = [fetch_data.remote(i) for i in range(1000)] while pending: done, pending = ray.wait(pending, num_returns=4) for ref in done: process(ray.get(ref))

还有一个容易忽略的点:不要在一个任务内对大量ObjectRef反复ray.get。比如[ray.get(ref) for ref in huge_ref_list]有可能把worker内存顶爆。改用ray.data或者分批获取更稳。

3.3 Actor状态丢失与回收策略

Action的坑在于它的生命周期。默认情况下,Actor一旦创建就活在当前集群里,释放它的唯一方式是:所有引用它的ObjectRef不再存在、进程退出,或者你显式调用ray.kill(actor)。很多人以为函数执行完Actor会自己销毁,但实际不是。

另一个场景是:你在一个临时Task里创建Actor,任务结束Actor直接被回收,内部尚未落盘的状态全部丢失。这在高并发的参数更新场景极其致命:参数服务器刚收了十个worker的更新,还没来得及checkpoint,进程挂了,更新没了。

解法:

  • 创建时指定lifetime="detached",让Actor脱离Driver进程存活,只靠名字访问,防止Driver退出就被回收。
  • 周期性地把Actor状态写到外部存储,比如Redis或S3,做checkpoint。
  • 收集Actor更新的任务别用一次性Task,用长期运行的Service任务管理Actor生命周期。

3.4 多个Worker的日志去哪了

这个问题我踩得最久。跑Ray集群训练,driver打印了一堆log,但worker里的print却看不到几条。原因是Worker是独立进程,它的stdout默认重定向到raylet的日志文件,不会回到driver。

排查的时候,如果你执行ray.get(worker_task.remote()),你会看到结果正常,但中间的print输出消失了。这不代表没执行,只是输出落到了别处。

解法按场景分:

  • 小量日志:把日志字符串放进返回值里,最后在driver统一打印。
  • 大量日志:用logging模块配置输出到文件,worker把日志写到共享目录或日志服务。
  • 用Ray的ray.util.logging插件,或者直接看日志目录/tmp/ray/session_latest/logs/,里面有每个worker的详细输出。

3.5 小任务太多时的调度开销

Ray对一个任务的调度开销大约在几十微秒到几毫秒之间,但这百万级别的任务就另说了。比如你想对1000万条纯Python数据逐条做remote调用——每秒最多提交几万任务,大量时间浪费在序列化和调度上,吞吐反而比单机低。

解法是批量。把1000万条数据分成1000个批次,每批包含1万条,提交1000个任务而不是1000万个任务。一个实测案例:原来逐行调用远程函数处理文件,跑了40分钟;改成按块读取、每块提交一个任务后,4分钟跑完,吞吐提升了10倍。

判断标准是:单个任务本身最好至少执行10毫秒以上,任务提交频率要远远小于任务执行频率,否则就应该合并任务粒度。

4. 资源与调度:Cluster、自动扩缩容和placement group的使用姿势

4.1 给任务分配CPU和GPU

Ray默认认为一个任务占用1 CPU,如果你是CPU密集型任务,想控制并发度,就得显式声明:

@ray.remote(num_cpus=2) def cpu_intensive_task(): ... @ray.remote(num_gpus=1) def gpu_task(): ...

有意思的是,num_cpus可以是小数。你在做IO密集型任务时,可以写num_cpus=0.5,让一个CPU核同时跑两个IO任务,提升吞吐。这个设计在超算领域叫"任务细粒度资源声明",很多框架没有,导致IO任务也得占满一核。

还有一个很实用的参数是memory:给任务预留内存,防止多个任务挤爆节点。比如@ray.remote(memory=4 * 1024 * 1024 * 1024)表示这个任务需要4GB内存配额,调度器会主动避开不够内存的节点。

4.2 placement group:把相关联的任务钉在一起

分布式计算有个经典矛盾:并行度高的任务希望尽量分散到不同机器(利用多机资源),但数据交换频繁的任务又希望尽量放在同一台机器(减少网络传输)。

Placement group就是解决这个矛盾的。它把一组资源打包,作为整体调度。比如有4个worker频繁交换中间结果,你可以申请一个包含4个CPU bundle的group,用策略PACK强制放同一台,或者用策略SPREAD分散到不同机器。

from ray.util.placement_group import placement_group pg = placement_group([{"CPU": 2}, {"CPU": 2}, {"CPU": 2}], strategy="PACK") ray.get(pg.wait_for_resources()) @ray.remote(placement_group=pg) def worker_task(): ...

我自己的经验是:模型并行训练时,同一组进程间的通信延迟极为敏感,用PACK;大规模参数搜索彼此独立、只想最大化机器利用率时,用SPREAD。不要上来就扣一个group,先确认你的任务是不是真的存在频繁通信,不然反而会把调度器绑死。

4.3 集群模式与自动扩缩容

Ray单机模式适合学习和开发,生产环境通常要跑在集群上。启动一个head节点:

ray start --head --port=6379

然后在每个worker节点执行:

ray start --address=<head-ip>:6379

这样节点就加入了集群。代码里ray.init(address="auto")会自动连接。加节点可以手动,也可以用Ray的自带自动扩缩容:配置集群的最小/最大节点数,负载高了自动加worker,闲置了自动回收。

# cluster.yaml 节选 cluster_name: ray-cluster max_workers: 10 provider: type: aws node_config: InstanceType: m5.xlarge available_node_types: worker: min_workers: 2 max_workers: 10 node_config: InstanceType: m5.xlarge

注意开自动扩缩容时,task的num_cpus声明一定要准确。如果每个任务都声明了1 CPU,集群就能精确算出当前还剩多少资源、需要加几台机器。如果全部不声明,Ray默认按1 CPU算,你的任务实际可能占用多核,就会导致自动扩缩容被"骗"了——以为集群很忙,实际每个任务都在占着多余的核,机器加个没完。

4.4 容错重试的配置策略

分布式环境里节点宕机是正常现象。Ray任务默认会在任务失败时重试3次。Actor默认不重试,因为它有状态,重试也不一定能恢复现场。

常用配置是:

@ray.remote(max_retries=-1) # 无限重试,适合重要任务但注意会掩盖代码bug def flaky_task(): ... @ray.remote(max_retries=2) def ordinary_task(): ...

另外一个容易被忽略的参数是max_calls,它控制一个worker进程最多执行多少个任务后自动退出。如果任务有内存泄漏,每跑几百个任务,worker内存就涨一点,最终被OOM。这时设置max_calls=100,让worker每执行100个任务就重启一轮进程,内存就到一个稳定值。这个参数在生产环境非常管用,算是隐藏的经验细节。

5. 典型场景拆解:超参搜索、并行数据处理和模型服务

5.1 超参搜索:从手动循环到并行网格

如果你不想引入额外的调参框架,直接用Ray Task做网格搜索特别舒服。把每一组超参变成一个独立任务,并行度拉满:

import itertools @ray.remote def train_model(lr, batch_size, num_layers): # 这里真正执行模型训练,返回验证集指标 score = lr * batch_size * num_layers * 0.001 # 示意 return {"lr": lr, "batch_size": batch_size, "score": score} param_grid = { "lr": [0.01, 0.001, 0.0001], "batch_size": [16, 32, 64], "num_layers": [2, 3, 4], } all_configs = [dict(zip(param_grid.keys(), v)) for v in itertools.product(*param_grid.values())] futures = [train_model.remote(**cfg) for cfg in all_configs] results = ray.get(futures) best = max(results, key=lambda item: item["score"]) print("最优配置:", best)

如果你追求更完整的超参搜索能力(比如早停、进化搜索、资源调度优化),Ray内置的Tune库可以直接用。它本质上是在Ray之上又包了一层实验管理,支持网格、随机、贝叶斯等搜索算法,还能配合权重与偏置平台做监控。我个人的建议:少于50组配置,直接用Task写循环,灵活可控;上百组配置,交给Tune,调度和早停都帮你省心。

5.2 并行处理文件型数据

RabbitMQ那类异步队列在处理一批批文件时,你要自己做切片、排队、收集。Ray做这个更贴近"并行for循环"的感觉:

import glob @ray.remote def process_one_file(path): # 模拟读取文件并计算 lines = open(path).read().splitlines() return {"path": path, "line_count": len(lines)} files = glob.glob("/data/logs/*.log") futures = [process_one_file.remote(path) for path in files] stats = ray.get(futures) print(f"共处理 {sum(s['line_count'] for s in stats)} 行")

如果数据本身就是结构化表格,建议直接看ray.data。它提供了read_csv、map_batches、groupby等API,底层会自动并行处理,还能做分布式训练的数据准备:

import ray.data as rd ds = rd.read_csv("s3://fake-bucket/*.csv") ds = ds.map_batches(lambda batch: batch + 1, batch_size=10000) result_count = ds.count()

用ray.data的好处是:数据不用你手动管理切片和合并,它在底层会用Ray对象存储自动传递中间数据表,配合训练框架时也能无缝衔接。

5.3 Ray Serve:把模型发布成HTTP接口

模型训练完只是一个开始,上线部署又是一套流程。Ray Serve把在线推理这一环也收编了进来。你可以直接在Ray集群里定义一个异步服务,不用再单独折腾Flask加Gunicorn加Docker:

from ray import serve import ray ray.init() serve.start() @serve.deployments(route_prefix="/predict", num_replicas=2) class Predictor: def __init__(self, model_path): self.model = load_model(model_path) async def __call__(self, request): data = await request.json() return self.model.predict(data["features"])

num_replicas决定了模型的副本数,Serve会自动做负载均衡。副本可以布局在同一节点或多节点,也可以配合placement group控制副本位置。如果你还在用ray.train训练模型,训练完甚至可以直接把模型权重挂到Serve上,不用单独导出去,流程上省不少事。

我实际用过的一个业务是把一个NLP分类模型部署到Serve,前后端用FastAPI风格接口对接。最大的体感是:不用再单独维护一个推理服务进程,训练的资源和推理的资源在同一个Ray集群里统一管理,高峰期扩推理副本、低峰期缩回来,成本控制也简单了很多。


最后再分享一个经验:别指望一个框架解决所有问题,选型时先想清楚"我的任务到底是CPU密集、数据密集,还是有状态服务"。Ray最舒服的场景是"任务间有关系、需要动态组装、同时想把计算能力横向扩展"这一类。如果你只是需要一个简单的异步任务队列,Celery可能更轻;如果只是跑一次性数据分析,Pandas加多进程也够了。但只要你开始为多机调度、共享状态、服务部署这三件事同时操心,Ray这套抽象会带来很大的便利。我现在的默认做法是:先在一个包含几台GPU机器的Ray集群里做实验,实验和上线用同一套Ray API,避免开发环境和生产环境两套代码互相折磨。分布式计算这件事,很多时候不是硬件不够,而是抽象层不对。

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

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

立即咨询