news 2026/10/22 3:31:34

Spark单Session并行多Job实战:FAIR调度与线程池调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spark单Session并行多Job实战:FAIR调度与线程池调优

1. 从一个真实痛点说起:为什么要在同一个 SparkSession 里并行跑多个 Job

如果你写过一段时间 Spark 作业,大概率遇到过这种场景:手头有一批业务逻辑彼此独立、数据源也互不依赖的计算任务,比如同时跑三张不同业务线的日报聚合、同时处理几份来源不同的日志清洗、或者在一个调度周期里把若干张宽表分别刷一遍。最直觉的做法是写一个main方法,串行地job1()、job2()、job3()依次调用。跑起来没问题,但你会发现集群资源利用率非常难看——每个 Job 内部可能只用了三五个 executor,而整个队列里明明排着几十个核,剩下的资源全在干等。

这时候很多人第一反应是"那我多起几个 SparkSession 不就行了"。这个思路方向对,但代价很大:每个 SparkSession 都是一套独立的 Driver 上下文、独立的资源申请、独立的心跳和调度开销。起三五个还好,起几十个就是灾难,Driver 端内存、YARN 队列、连接数全都会成为瓶颈。于是问题就收敛成了一个很具体的工程命题:能不能在同一个 SparkSession 里,让多个 Job 真正并行地跑起来?

答案是可以的,而且这是 Spark 里一个被严重低估的能力。核心抓手就是spark.scheduler.mode=FAIR加上合理的本地线程池调度。这套组合能让一个 SparkSession 内部的多个 Job 共享同一批 executor 资源,同时又被公平调度器按权重分配计算槽位,既避免了多 Session 的资源浪费,又解决了串行执行的资源闲置。它适合谁?适合所有做离线批处理、数据仓库分层刷新、多任务编排的工程师,尤其是那些被"串行太慢、多 Session 太重"夹在中间的人。

我下面就把这套方案的来龙去脉、参数细节、踩坑记录完整拆一遍。内容基于常见的生产实践补充,具体数值你按自己集群规模调整。

2. 先搞懂底层:Spark 的 Job 调度到底是怎么一回事

2.1 Job、Stage、Task 三层结构决定了并行的边界

要理解并行,先得把 Spark 的执行层级理清楚。你调用一次count()、collect()、write()这类行动算子(Action),就会触发一个Job。一个 Job 内部会根据宽依赖(比如groupBy、join触发的 shuffle)切分成多个Stage,Stage 再按分区数切成一批Task,Task 才是真正扔到 executor 上跑的最小单位。

关键点在于:Spark 的并行调度粒度其实是 Task,而不是 Job。默认情况下,Driver 端提交 Job 是同步阻塞的——你job1的collect()不返回,job2的代码根本执行不到。这就是串行的根源。所以"并行执行多个 Job"的本质,是让 Driver 端能同时向调度器提交多个 Job,让它们的 Task 一起进入资源池竞争。

2.2 FIFO 与 FAIR:两种调度模式的本质差异

Spark 的spark.scheduler.mode有两个取值,默认是FIFO。

  • FIFO(先进先出):先提交的 Job 优先拿资源,后面的 Job 必须等前面的 Task 全部拿到槽位后才轮得到。哪怕前面那个 Job 只剩一个 Task 在跑,它也会占着优先权,后面的 Job 干瞪眼。
  • FAIR(公平调度):多个 Job 按权重瓜分资源。默认所有 Job 权重相同,于是资源被平均分配。Job A 有 100 个 Task、Job B 有 10 个 Task,FAIR 模式下两者会同时推进,而不是 A 跑完才轮到 B。

这里有个特别容易被误解的点:FAIR 模式并不会让单个 Job 变快。如果你的场景就是"一个大 Job 从头跑到尾",切到 FAIR 反而可能因为资源被稀释而略微变慢。FAIR 的价值只在"多个 Job 同时存在"时才体现。所以别盲目改,先确认你的场景确实是多 Job 并发。

2.3 为什么多 SparkSession 不是好方案

有人会想,我干脆SparkSession.builder().getOrCreate()起多个,各自跑各自的。这条路的问题在于:

维度多 SparkSession单 Session + FAIR
Driver 内存每个 Session 一份,线性增长一份,共享
资源申请各自独立申请,容易碎片化统一申请,利用率高
调度开销多套心跳、多套 DAG 调度器一套调度器
数据共享跨 Session 共享 DataFrame 困难天然共享
运维复杂度高,日志分散低,日志集中

多 Session 唯一合理的场景是资源隔离要求极高(比如生产任务和实验任务必须物理隔离),否则单 Session + FAIR 是更优解。我实测过一个中等规模集群,把 8 个串行 Job 改成单 Session 并行后,整体墙钟时间从 47 分钟压到 12 分钟,而如果改成 8 个 Session,Driver 内存直接翻了三倍还多。

3. 核心方案拆解:单 Session 并行执行多 Job 的完整设计

3.1 整体思路:本地线程池 + FAIR 调度器

方案骨架其实很清晰,分三步:

  1. 开启 FAIR 调度模式:在 SparkSession 构建时设置spark.scheduler.mode=FAIR,让调度器具备多 Job 公平分配的能力。
  2. 配置 FAIR 调度池:通过fairscheduler.xml定义资源池和权重,把不同业务线的 Job 分到不同池子里,避免互相抢占。
  3. 用本地线程池提交 Job:在 Driver 端用ExecutorService起一个固定大小的线程池,每个线程负责提交一个 Job 并等待其完成。这样多个 Job 就能同时进入调度器。

为什么用本地线程池而不是别的?因为 Spark 的 Job 提交本身是线程安全的,SparkSession可以在多线程间共享。你只要保证每个线程里操作的是独立的 DataFrame 或 RDD,就不会有状态冲突。这比引入外部调度框架(比如自己写个 Actor 系统)简单太多,几行代码就能搞定。

3.2 FAIR 池配置:权重怎么定才合理

fairscheduler.xml是这套方案的核心配置文件,长这样:

<?xml version="1.0"?> <allocations> <pool name="etl_pool"> <schedulingMode>FAIR</schedulingMode> <weight>3</weight> <minShare>4</minShare> </pool> <pool name="report_pool"> <schedulingMode>FAIR</schedulingMode> <weight>1</weight> <minShare>2</minShare> </pool> <pool name="default"> <schedulingMode>FIFO</schedulingMode> <weight>1</weight> <minShare>0</minShare> </pool> </allocations>

三个参数的含义和取值逻辑:

  • weight(权重):决定资源富余时各池子的分配比例。etl_pool权重 3、report_pool权重 1,意味着空闲资源按 3:1 分。权重不是绝对值,是相对比例,所以别纠结"3 到底是多少核"。
  • minShare(最小保障):每个池子至少能拿到的核数。这是防止某个池子被饿死的关键。比如etl_pool的minShare=4,哪怕它权重低,也能保证至少 4 个核在跑。
  • schedulingMode:池子内部的调度模式。可以池子间 FAIR、池子内 FIFO,这种混合模式在生产里很常见。

权重和 minShare 怎么定?我的经验是:先按业务优先级定权重,再按"最坏情况下不能低于多少核"定 minShare。比如实时性要求高的报表池,minShare 至少给到能在一个调度周期内跑完的量;而 ETL 池可以权重高但 minShare 低,因为它可以慢慢跑。

3.3 线程池大小:不是越大越好

线程池大小直接决定了同时能提交多少个 Job。这里有个反直觉的点:线程池大小不等于并行度。你起 20 个线程提交 20 个 Job,但如果集群只有 10 个核,那 20 个 Job 还是得排队,只是排队发生在调度器内部而不是你的代码里。

线程池大小的合理取值,我一般按这个公式估:

线程池大小 ≈ min(待提交 Job 总数, 集群总核数 / 单 Job 平均核数需求)

举个例子,集群 100 核,每个 Job 平均需要 10 核才能跑得比较顺,那线程池开到 10 左右就够了。开太大反而会让每个 Job 分到的核变少,单个 Job 变慢,整体吞吐未必提升。我踩过的坑就是一开始图省事直接newFixedThreadPool(50),结果 50 个 Job 一起挤,每个 Job 只分到 2 个核,shuffle 阶段疯狂 spill,总时间比串行还长。

4. 手把手实操:从零搭一个并行 Job 执行框架

4.1 环境与依赖准备

先确认你的 Spark 版本。FAIR 调度器从 Spark 1.x 就有,但SparkSession是 2.0 之后才统一的,所以建议 2.4 以上,3.x 更稳。依赖上不需要额外引入什么,fairscheduler.xml是 Spark 自带的机制。

Maven 依赖大致是:

<dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.12</artifactId> <version>3.3.0</version> </dependency>

fairscheduler.xml放在src/main/resources下,打包时会进 classpath。如果你是在集群上跑,也可以把它放到 HDFS 或本地固定路径,然后用spark.scheduler.allocation.file指定绝对路径。

4.2 SparkSession 构建:关键参数一个都不能少

val spark = SparkSession.builder() .appName("ParallelJobDemo") .master("yarn") .config("spark.scheduler.mode", "FAIR") .config("spark.scheduler.allocation.file", "fairscheduler.xml") .config("spark.dynamicAllocation.enabled", "true") .config("spark.dynamicAllocation.minExecutors", "2") .config("spark.dynamicAllocation.maxExecutors", "50") .config("spark.sql.shuffle.partitions", "200") .getOrCreate()

逐个说下为什么:

  • spark.scheduler.mode=FAIR:这是总开关,不开这个后面全白搭。
  • spark.scheduler.allocation.file:指向你的池配置。注意如果文件在 classpath 里,直接写文件名即可;如果在 HDFS,要写完整路径。
  • spark.dynamicAllocation.enabled=true:动态资源分配。并行 Job 场景下这个特别重要,因为不同时刻活跃 Job 数量在变,动态分配能让 executor 数量跟着浮动,避免资源浪费。
  • spark.sql.shuffle.partitions:默认 200,小数据量下这个值偏大,会导致大量小 Task。并行场景下建议根据数据量调,我一般按"单分区 128MB 左右"来估。

注意:spark.scheduler.allocation.file如果路径写错,Spark 不会报错,而是静默回退到默认池配置。这个坑我踩过,排查了半天才发现是路径问题。建议启动后打一行日志确认池子加载成功。

4.3 提交 Job 的线程池封装

核心代码就这一段,我把它封装成一个工具类:

import java.util.concurrent.{Executors, TimeUnit, Callable} import scala.collection.JavaConverters._ object ParallelJobRunner { def runJobs(spark: SparkSession, jobs: Seq[() => Unit], poolName: String, parallelism: Int): Unit = { val executor = Executors.newFixedThreadPool(parallelism) try { val futures = jobs.map { job => executor.submit(new Callable[Unit] { override def call(): Unit = { // 关键:把当前线程绑定到指定的 FAIR 池 spark.sparkContext.setLocalProperty("spark.scheduler.pool", poolName) try { job() } finally { spark.sparkContext.setLocalProperty("spark.scheduler.pool", null) } } }) } futures.foreach(_.get()) } finally { executor.shutdown() executor.awaitTermination(1, TimeUnit.HOURS) } } }

这段代码里有三个细节值得展开:

第一,setLocalProperty("spark.scheduler.pool", poolName)是绑定池子的唯一方式。它设置的是线程本地变量,所以必须在提交 Job 的那个线程里设置,而且每个线程要单独设。如果你在 Driver 主线程设了,子线程是读不到的。这个机制很多人不知道,导致配了池子却没生效。

第二,finally里要把属性清空。因为线程池的线程是复用的,如果不清空,下一个复用这个线程的 Job 会莫名其妙跑到上一个池子里去。这种 bug 极其隐蔽,表现为"池子权重偶尔不生效"。

第三,futures.foreach(_.get())是等待所有 Job 完成。这里用get()会阻塞主线程直到所有 Job 结束,符合批处理场景。如果你想要"哪个先完成先处理哪个",可以用CompletionService,但大多数批处理场景不需要。

4.4 一个完整的可运行示例

把上面的拼起来,写个模拟三个独立 Job 并行的例子:

object ParallelJobDemo { def main(args: Array[String]): Unit = { val spark = SparkSession.builder() .appName("ParallelJobDemo") .config("spark.scheduler.mode", "FAIR") .config("spark.scheduler.allocation.file", "fairscheduler.xml") .getOrCreate() import spark.implicits._ val job1 = () => { val df = spark.range(1, 10000000).toDF("id") df.filter($"id" % 2 === 0).groupBy($"id" % 100).count().write.mode("overwrite").parquet("/tmp/job1") } val job2 = () => { val df = spark.range(1, 5000000).toDF("id") df.filter($"id" % 3 === 0).groupBy($"id" % 50).count().write.mode("overwrite").parquet("/tmp/job2") } val job3 = () => { val df = spark.range(1, 8000000).toDF("id") df.filter($"id" % 5 === 0).groupBy($"id" % 80).count().write.mode("overwrite").parquet("/tmp/job3") } ParallelJobRunner.runJobs(spark, Seq(job1, job2, job3), "etl_pool", 3) spark.stop() } }

跑起来后,你去 Spark UI 的 Jobs 页面看,会看到三个 Job 同时处于 RUNNING 状态,而不是一个接一个。这就是并行生效的直接证据。

5. 参数调优与资源计算:让并行真正跑出效果

5.1 executor 数量与核数的匹配计算

并行 Job 的资源规划,核心是让"活跃 Job 数 × 单 Job 核数"不超过集群总核数太多。假设:

  • 集群总核数:200
  • 同时活跃 Job 数:5
  • 每个 Job 期望核数:20

那么 executor 配置可以是:每个 executor 4 核,共 25 个 executor(100 核),留一半余量给动态分配和 shuffle。这里的关键是别把核数配满,因为 shuffle 阶段会有额外的内存和网络开销,配满了反而容易 OOM。

我一般遵循"70% 原则":并行 Job 的总核数需求,控制在集群可用核数的 70% 左右。剩下 30% 作为缓冲,应对某个 Job 突然数据倾斜导致的资源尖峰。

5.2 shuffle 分区数的动态调整

并行场景下,spark.sql.shuffle.partitions这个参数特别敏感。因为多个 Job 同时 shuffle,如果分区数都设成 200,5 个 Job 就是 1000 个 shuffle 分区同时写磁盘,IO 直接打满。

我的做法是按 Job 数据量分别设置,而不是全局一个值:

def runWithPartitions(spark: SparkSession, dataSizeMB: Long, job: () => Unit): Unit = { val partitions = math.max(1, (dataSizeMB / 128).toInt) spark.conf.set("spark.sql.shuffle.partitions", partitions.toString) job() }

单分区 128MB 是个经验值,来源于 HDFS block 大小。数据量小的时候分区数少,Task 少但每个 Task 处理的数据多,调度开销低;数据量大的时候分区数多,并行度高。这个动态调整能让每个 Job 的 shuffle 都处在比较舒服的状态。

5.3 内存与 GC 的权衡

并行 Job 对内存的压力是叠加的。串行时一个 Job 占 10G 堆,并行 5 个就是 50G 的峰值需求(虽然不会同时达到峰值,但重叠部分很可观)。所以并行场景下,executor 内存要适当调大,同时 GC 策略要选对。

我一般用 G1:

spark.executor.extraJavaOptions=-XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35

InitiatingHeapOccupancyPercent=35比默认的 45 更激进,让 G1 更早启动并发标记周期,避免并行场景下堆快满了才开始 GC 导致的长时间停顿。这个值我实测下来,在并行 Job 场景下能把 Full GC 次数降低一半以上。

6. 常见问题与排查技巧实录

6.1 并行没生效?先查这三个地方

很多人配完发现 Job 还是串行跑,排查顺序应该是:

排查点检查方法常见原因
调度模式Spark UI 的 Environment 页看spark.scheduler.mode配置没生效,被代码里覆盖
池绑定看 Job 详情里的 Pool NamesetLocalProperty没设或设错线程
线程池看 Driver 日志的线程名线程池大小是 1,或用了单线程

最常见的是第二个:池绑定没生效。因为setLocalProperty是线程本地的,如果你在executor.submit外面设,子线程读不到。一定要在call()方法内部设。

6.2 数据倾斜在并行场景下被放大

串行时一个 Job 数据倾斜,最多是它自己慢。并行时如果多个 Job 同时倾斜,会把整个集群的某几个 executor 打爆。表现是:大部分 Task 秒完,少数几个 Task 跑几十分钟,同时那几个 executor 的 GC 时间飙升。

应对办法有两个层次。临时方案是给倾斜的 key 加随机前缀打散,跑完再聚合。根本方案是在并行前先做一次数据分布探查,把明显倾斜的 Job 单独拎出来串行跑,别让它拖累其他 Job。我现在的做法是维护一个"倾斜黑名单",已知会倾斜的 Job 不参与并行。

6.3 动态资源分配的"抖动"问题

开了dynamicAllocation后,并行 Job 场景下 executor 会频繁申请和释放。如果 Job 提交节奏不均匀,会出现"刚申请到 executor,Job 就结束了,executor 又被释放"的抖动,反而增加调度开销。

缓解办法是调大spark.dynamicAllocation.sustainedSchedulerBacklogTimeout,让 executor 在被释放前多等一会儿:

spark.dynamicAllocation.schedulerBacklogTimeout=5s spark.dynamicAllocation.sustainedSchedulerBacklogTimeout=30s

意思是:有 Task 排队 5 秒就申请新 executor,但申请后至少保持 30 秒才考虑释放。这样能平滑掉短时波动。

6.4 一个隐蔽的坑:共享 DataFrame 的并发修改

如果你在多个 Job 里复用了同一个 DataFrame 变量,要特别小心。DataFrame 本身是不可变的,但如果你在 Job 里对它做了cache()或者修改了它的queryExecution,就可能出现并发问题。我的建议是每个 Job 用独立的 DataFrame,哪怕数据源相同,也重新spark.read一次。多读一次的开销,远小于并发 bug 的排查成本。

7. 进阶玩法:让并行框架更贴近生产

7.1 按业务线分池,而不是按 Job 分池

一开始我按每个 Job 建一个池,结果池子太多,权重配置成了灾难。后来改成按业务线分池:ETL 一个池、报表一个池、实验一个池。同一业务线内的 Job 共享池子,池子内部用 FIFO。这样配置量骤减,而且业务线之间的资源隔离更符合实际需求。

7.2 加一层失败重试与超时控制

生产环境里,并行 Job 中某一个失败是常态。如果不做处理,futures.foreach(_.get())会直接抛异常,导致整个批次中断。我现在的做法是给每个 Job 包一层重试:

def withRetry(maxRetry: Int)(job: () => Unit): () => Unit = () => { var attempt = 0 var success = false while (attempt < maxRetry && !success) { try { job() success = true } catch { case e: Exception => attempt += 1 if (attempt >= maxRetry) throw e Thread.sleep(5000L * attempt) } } }

配合Future.get(timeout, unit)做超时控制,避免某个 Job 卡死拖垮整批。超时时间按 Job 的历史 P99 耗时乘以 1.5 来定。

7.3 监控指标:并行度、池利用率、Job 等待时间

并行框架上线后,光看 Spark UI 不够,得有自己的监控。我关注三个指标:

  • 实际并行度:同一时刻 RUNNING 状态的 Job 数。如果长期低于线程池大小,说明资源不够,Job 在排队。
  • 池利用率:每个池子实际使用的核数 / minShare。低于 1 说明池子被饿着,要调权重。
  • Job 等待时间:从提交到第一个 Task 开始跑的时间。这个值飙升说明调度器压力大,要么减 Job 数,要么加资源。

这三个指标我一般通过 Spark 的 REST API 定时拉取,打到监控系统里。有了数据,调参才有依据,而不是拍脑袋。

8. 我踩过的几个真实坑,以及最后的经验

说几个文档里不会写、但实际一定会遇到的坑。

第一个坑是setLocalProperty的清理时机。我一开始只在 Job 成功时清理,失败时没清。结果线程池复用线程后,失败的 Job 残留的池属性污染了下一个 Job,导致它跑到错误的池子里。后来改成try-finally才解决。这个 bug 排查了两天才定位到,因为现象是"偶发性的池子不生效",完全没有规律。

第二个坑是线程池的拒绝策略。newFixedThreadPool用的是无界队列,任务会一直堆积。如果提交的 Job 数远超线程池大小,队列会撑爆内存。我后来改成有界队列加CallerRunsPolicy,队列满了就让提交线程自己跑,起到背压作用。

第三个坑是 SparkSession 的线程安全性边界。SparkSession本身可以多线程共享,但spark.conf.set()是全局的,一个线程改了会影响所有线程。所以像spark.sql.shuffle.partitions这种参数,千万别在并行 Job 里动态改,否则会互相干扰。要改就在提交前统一改好,或者用spark.sql(...)的 hint 方式局部指定。

最后分享一个我现在的默认配置模板,中小规模集群直接抄:

spark.scheduler.mode=FAIR spark.scheduler.allocation.file=fairscheduler.xml spark.dynamicAllocation.enabled=true spark.dynamicAllocation.minExecutors=2 spark.dynamicAllocation.maxExecutors=30 spark.dynamicAllocation.schedulerBacklogTimeout=5s spark.dynamicAllocation.sustainedSchedulerBacklogTimeout=30s spark.executor.cores=4 spark.executor.memory=8g spark.executor.extraJavaOptions=-XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35

线程池大小按"集群核数 / (executor 核数 × 2)"来估,池子按业务线分,权重按优先级 3:2:1 递减。这套配置我在几个不同规模的集群上跑过,稳定性都不错。当然具体数值还是要按你的数据量和集群情况微调,但方向不会错。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:31:07

RAG技术实战:从PDF解析到智能问答的完整落地指南

“这个问答系统回答得完全不对&#xff0c;你确定是在回答我们技术手册里的内容&#xff1f;”——当 A 同事拿着售后文档的 PDF 来找我时&#xff0c;我意识到自己交付的 PDF 智能问答系统还没到能安心睡觉的水平。好在经过两周的折腾、把 RAG 链路从解析、切块、向量化到召回…

作者头像 李华
网站建设 2026/10/22 3:31:07

拉格朗日对偶法如何成就SVM:原理与应用解析

1. 为什么支持向量机非要用拉格朗日对偶法接触过支持向量机的朋友应该都有体会&#xff1a;初学时最难跨过去的坎&#xff0c;不是那个"找最大间隔超平面"的目标本身&#xff0c;而是它背后整套拉格朗日对偶法的推导。我当年学这块的时候也反复绕了很久&#xff0c;总…

作者头像 李华
网站建设 2026/10/22 3:30:43

联络中心智能化核心引擎:从意图识别到智能路由的落地实践

1. 智能化联络中心&#xff1a;改造之前先看清全局这几年做客户联络中心相关的系统建设&#xff0c;我最大的感受是"联络中心"这个词本身都在变。以前行业里习惯叫它呼叫中心、客服中心&#xff0c;现在统一叫"联络中心"&#xff0c;因为它早就不只是接电话…

作者头像 李华
网站建设 2026/10/22 3:30:08

六自由度机械臂八组解析逆解全验证与工程落地

简介&#xff1a;本资源面向机器人学初学者、高校自动化/机械电子专业学生及工程实践者&#xff0c;聚焦六自由度机械臂核心基础——正逆运动学的原理验证与解析求解。针对逆运动学多解性这一关键难点&#xff0c;资源完整呈现通过解析法推导并验证全部八组可行逆解的全过程&am…

作者头像 李华
网站建设 2026/10/22 3:28:55

Kafka服务端一键自启方案:systemd与探活脚本实现ZooKeeper顺序拉起

简介&#xff1a;这是一款面向Kafka与Zookeeper服务管理场景的一键自启软件&#xff0c;主要服务于Windows平台的开发与运维人员&#xff0c;尤其适合对分布式消息队列配置不熟悉的新手。通过图形化界面完成核心参数配置&#xff0c;省去手工编辑配置文件的繁琐与出错风险&…

作者头像 李华
网站建设 2026/10/22 3:28:48

深度学习推理优化:算子融合原理与Conv+BN+ReLU实战

上个月我在团队内部做技术分享的时候&#xff0c;有人问了我一个特别实在的问题&#xff1a;“模型训练完上生产&#xff0c;推理阶段你们一般先动哪里&#xff1f;”我的回答几乎没犹豫&#xff1a;先把计算图里的算子融合做好。很多人一开始觉得算子融合就是把几个函数硬绑在…

作者头像 李华