“你这集群吞吐能到多少”和“我提交的那个训练任务大概什么时候能跑完”——这两年我发现自己被问到后一个问题的次数越来越多。这两个问题的答案往往差得很远。前一个数字再漂亮如果任务在队列里半天不动用户感受到的依然是“慢”。这就是我读PipeSwift这篇论文时觉得特别对胃口的原因它把分布式训练里流水线并行的优化目标从集群吞吐量最大化拉到了作业完成时间JCT最短。读完之后我的整体判断是这不是一篇“发明新并行方式”的论文而是一篇把“调度思维”嫁接到“流水线并行”上的论文。它同时动了两个层次的东西——作业怎么排队、排上之后怎么切流水线并把它们放在同一个优化框架里。如果你在做大模型训练平台、GPU集群调度或者手里正握着一堆排队的训练任务这篇论文的思路会很有参考价值。1. 为什么JCT会成为流水线并行的新焦点1.1 高吞吐量不等于短的作业完成时间先明确两个概念。吞吐量throughput是单位时间内集群完成的计算量比如每秒处理多少个token、多少个batchJCTJob Completion Time则是一个作业从提交到结束总共花了多久。前者衡量的是“系统的产出”后者是“用户的等待”。一个简单的例子就能说明两者的差距某集群负载接近100%每秒处理的数据量非常漂亮但新提交的作业要排在前面几个大作业后面排队时长可能超过作业本身的计算时长。这个时候你再怎么优化流水线、压低气泡用户的JCT也并不会变短因为瓶颈在调度层而不是执行层。我习惯用一个快递的类比吞吐量相当于整车运输每小时能走多少吨货JCT则是一单快递从寄出到签收的时间。整车装满、高速运转不代表你那一单就快。PipeSwift想优化的恰恰是后者而且它把优化的触手伸到了“整车怎么装货”这一层。以前大家只管把车装满跑得久远现在开始琢磨“每一单什么时候能送到用户手里”。1.2 流水线并行里的气泡到底在浪费什么时间流水线并行把模型切分成P个stage放到不同设备上训练数据再拆成M个micro-batch像工厂流水线一样依次流过这些stage。理想情况下每个stage同时在算不同的micro-batch设备满载。但流水线有一个天生的毛病启动和收尾阶段必有气泡。这里有一个流水线并行里经常被引用的气泡率公式bubble占比约为 (P-1)/(MP-1)。当M远大于P时这个比例趋近0当M和P数量级相当例如P4、M4的时候气泡比例高达3/7也就是约43%的时间设备在空转。所以过去几乎所有流水线并行的工作包括GPipe、PipeDream、1F1B、交错调度核心目标就一个字降低bubble。手段无非是增加micro-batch数量、改造调度顺序、缩小stage间通信代价。这些本质上都是从“吞吐量最大化”出发的做法它们假设的是“一个作业必须把流水线吃满、跑久”但并没有回答“如果作业本身不大这套逻辑还成立吗”。1.3 小作业在流水线里吃过的哑巴亏有一个点过去被关注得比较少bubble优化对“小作业”并不友好。假设一个作业的总数据量只够拆成8个micro-batchP4的时候启动和收尾阶段带来的固定空转时隙占比依然很高你再怎么调调度顺序这部分的绝对时间也省不掉多少。但站在JCT角度看小作业恰恰是最在乎完成时间的用户只跑几百步微调关心的是“多久出结果”而不是这一小时的训练里集群有没有满载。PipeSwift的一个关键贡献就是把这类“作业粒度上的启动成本”正式纳入优化变量不再默认“所有作业都按大吞吐量模式跑”。这个视角转换是整个论文的题眼过去我们研究流水线默认目标函数是“单位时间完成多少训练量”PipeSwift把目标函数换成了“作业什么时候能做完”。目标函数一变很多结论就都变了。2. PipeSwift的设计思路JCT、吞吐量与解空间2.1 从“把单个作业跑满”到“把一群作业跑完”PipeSwift对JCT的定义很朴素JCT等于排队时间加执行时间。传统的集群调度器管排队传统流水线方案管执行它们各管一段中间几乎没有信息交互。PipeSwift做的事情简单说就是把这两段放进同一个优化问题里。用一个小例子说明收益。假设集群有8张GPU大作业A按8卡满配跑需要4小时小作业B和小作业C各需要2卡、各跑1小时。FIFO策略下A先占满集群B和C只能排在后面它们的JCT分别是4.5小时和5小时加上A的4小时平均JCT约4.5小时。如果让A改用4卡跑假设需要约8小时完成把B和C各分配2卡并行跑那么B和C的JCT就只有1小时A的JCT是8小时平均JCT降到约3.3小时。大作业变慢了但整体体验明显变好前提是大作业的SLA允许这种牺牲。看起来简单但要在流水线并行场景落地并不容易你需要能预估每个作业的执行时间需要能动态改变作业占用的GPU数还需要能同时调整流水线的stage划分。这些需求把调度器、资源管理器、训练框架三层都牵动了PipeSwift的论文主线基本就是在解决“这三层怎么相互配合”。2.2 同一作业可以有多种“跑法”流水线并行的参数空间比看起来大得多一个作业可以用P2跑也可以用P4跑可以用M8个micro-batch也可以用M64个每个micro-batch的batch size还能变。每换一组参数完成时间都会变。为什么不能总选“吞吐量最高”的那组参数因为高吞吐参数的代价往往是用更大的资源占用或更长的收敛时间换来的。对于一个只有几百步的训练作业最“划算”的配置可能是少用卡、少吃显存、把bubble弄大一点但整体启动快、排队时间短。PipeSwift把这组变量统称为作业的“执行配置”并允许同一个作业根据集群实时状态选择不同配置。这是我读论文时感受最强的一点在它之前分布式训练基本是“资源定了、并行策略定了、作业就这样跑”现在变成“作业是一个可塑的东西调度器可以根据整个集群的情况给每个作业凹一个最合适的造型”。2.3 micro-batch不是想调就能调既然流水线参数是可变的为什么不干脆把所有micro-batch都调得特别小、把M弄得特别大这里有个来自收敛性的硬约束micro-batch太小梯度噪声变大模型收敛变慢甚至不收敛宏观表现就是总训练步数变多JCT反而上升。微批大小对收敛的影响可以理解成“每一次参数更新的依据不够可靠”。一个micro-batch只有4条样本梯度估算方差很大模型在山谷里反复震荡走不动路。虽然流水线跑得快模型却不“学”得快。这类约束在论文里被处理成不等式条件每个micro-batch的batch size不能低于某个阈值估计的总训练步数要和历史日志匹配。PipeSwift的优化器不是无约束搜索而是在这些现实约束下做逼近最优的工作。这也是它在工程意义上比“纯理论调度”更值钱的地方它没有把训练框架当成黑盒而是知道黑盒内部哪些旋钮不能乱拧。3. PipeSwift的核心机制拆解3.1 作业画像与JCT预估要做调度决策首先得能预估作业执行时间。PipeSwift的预估方式粗看是两层先根据模型结构和层配置估算出单个micro-batch在某个stage上的前向/反向耗时这属于算子级的计算量和通信量估算再结合流水线schedule推算出整个作业的期望执行时长。这里有意思的一点是论文对预估精度的要求并不高。因为调度排序本质上只需要“相对大小”误差50%可能也够用。真正要命的是“系统性偏差”如果某种模型的耗时总是被低估20%所有同类型作业都会排到前面形成假优先级。所以真正的工程问题不是提高预测精度而是校准偏差。我自己的经验是调度器里的预估模型并不需要做到“完美预测”但需要做到“对不同模型类型不偏不倚”。否则你看着调度器把一堆中型作业提到前面以为JCT优化很成功细看发现每个作业都晚于预期只是相对顺序乱了实际体验并没有变好。3.2 排队策略短作业优先与饥饿处理PipeSwift在决策层采用类似“最短预计完成时间优先”的思路但直接套用SJF会有严重的饥饿问题大作业可能永远被新来的小作业挤到后面。论文里的处理方式是引入过期机制和公平预算类似Linux CFS调度里的虚拟时间概念保证每个作业的等待时长都有上限。另一个值得提醒的点GPU作业的“短”和CPU作业的“短”不是一个量级。一个GPU训练作业从启动到加载模型可能就要几分钟调度器如果频繁重排光模型加载成本就吃掉了所有收益。所以PipeSwift虽然想学操作系统的调度但它必须把“切换代价”显式建模进去。这也是我从这篇论文里学到的实际经验调度收益一定要减去上下文切换成本再判断值不值得。很多团队在设计JCT调度时会把重启作业的代价想得过于乐观结果优化了个寂寞。3.3 与底层流水线schedule的层次关系有些人读这类文章可能会问1F1B和GPipe哪个好这其实不是一个维度的问题。PipeSwift并不是发明一种新的micro-batch调度顺序而是在更上层判断“用几个stage、多少个micro-batch、怎么分配GPU”。等这些决定做好之后底层的执行依然交给Standard schedule或者Interleaved schedule来完成。可以把层次理解成两层上层是“作业参数选择器”回答“这个作业应该以什么形态跑”下层是“流水线执行器”回答“给定形态后micro-batch怎么在stage间流转”。PipeSwift主要替换的是上面那层下面那层保持兼容。这种层次分离的设计让它的工程侵入性比想象中小。你不需要推翻现有的Megatron或DeepSpeed流水线实现只需要在它们外面包一层“配置决策模块”让调度器能控制作业的并行度和批大小。3.4 一个容易被忽略的收益点资源碎片利用多租户GPU集群里最常见的问题不是“没有卡”而是“卡数不齐”。今天空闲3张A100明天空闲5张调度器如果只认“8卡整机”这些碎片全浪费了。碎片化的GPU既不能跑大作业又不够整机分配用户只能等着。PipeSwift因为支持动态调整stage数量天然具备“卡多能跑、卡少也能跑”的能力。3张空闲卡可以配置成3-stage流水线5张卡配置成5-stage虽然bubble比例未必最优但总比把碎片卡晾着强。这一点我在实际集群里深有体会资源碎片往往比高峰排队更拖累JCT。把碎片卡利用起来小作业就能插队执行根本不需要挤占大作业的资源是一种“双赢”的优化。很多JCT调度方案只关注排队没注意到碎片PipeSwift把这块也放进来了确实更接近真实集群的痛点。4. 与经典方案相比PipeSwift站在什么位置4.1 一张表看四种系统方案层级优化目标是否感知流水线内部参数适用场景GPipe流水线schedule吞吐量/bubble否只关注bubble大型作业训练PipeDream/1F1B流水线schedule吞吐量显存部分常规分布式训练Tiresias集群调度JCT否黑盒作业GPU集群多租户PipeSwift调度流水线参数联合JCT是混合负载、中小作业为主的共享集群这张表能说清楚一件事PipeSwift既不和GPipe抢“并行策略”的位子也不和Tiresias直接竞争“通用调度”的位子它堵的是两者之间的缝——调度器不感知流水线参数流水线参数不反馈给调度器。而这个缝恰恰是JCT优化空间最大的地方。4.2 为什么不能拿Tiresias直接改Tiresias是JCT调度的经典系统足够优秀但它把作业当成一个黑盒作业内部的模型并行策略、stage数量、micro-batch大小一概不碰。在某些场景下这并不致命但到了大模型训练时代同一个模型在不同配置下耗时可差几倍黑盒调度会丢掉大量优化空间。另一个方向DAPPLE这类工作把并行策略选择和集群资源分配放到一起做优化但它更偏向数据并行与模型并行的组合选择对流水线内部micro-batch级的细节操作颗粒度不够。PipeSwift的价值恰恰体现在颗粒度上它不仅决定“作业拆成几个stage”还参与决定“每个stage怎么跑”调度器的决策直接映射到框架层的参数上。在真实的训练集群里这两个环节往往是不同团队负责的搞基础设施的人管调度搞模型训练的人管并行策略两个团队很少坐下来把参数一起调。PipeSwift这类方案出现之后这种“部门墙”带来的性能损耗就再也藏不住了。4.3 从论文设计的取舍看未来方向我读这篇论文最大的收获是看到了一个明显趋势未来的GPU集群调度不可能继续停留在“给作业分配多少张卡”这种粗粒度它一定会往下钻钻到“作业内部怎么并行、怎么切分、怎么配置”的层面。这背后的驱动力是算力成本上升带来的个性化调度需求。同一个集群里可能同时跑着几十步的小微调任务、上千步的预训练任务、需要SLA的在线推理任务它们对延迟、吞吐、预算的偏好完全不同。一个只能“平均用力”的调度器不可能让所有类型的任务都满意。PipeSwift给了一个示范把目标函数说清楚把变量放进去优化剩下的交给算法。它的意义不在于那一个具体的启发式算法多精巧而在于展示了“联合优化调度决策与流水线配置”这条路是走得通的。5. 实验结果里我最看重的三个信号5.1 JCT到底降了多少论文在模拟器上复现了带真实硬件参数的多租户工作负载并与几种基线比较包括最简单的FIFO加默认GPipe配置、以及按Tiresias式短作业优先但固定流水线配置。就论文报告的量级看PipeSwift方案下的平均JCT有约20%到30%的下降作业规模越小收益越明显大作业接近持平。这个结果其实很符合直觉小作业最大的损耗在排队和启动开销PipeSwift做的正是优化这两个部分大作业本身就是吃资源的大户怎么调度都很难让它们再快多少能不被降速太多就算成功。真正让我在意的不是“平均降了30%”这个数字而是收益分布——它说明这套思路对“小作业多、提交频繁”的在线化集群收益显著。5.2 Bubble率上升不代表失败一个非常有趣的现象是在某些负载下PipeSwift选出的配置bubble率比“理论最优吞吐”配置更高但总体JCT反而更低。这乍看反直觉其实正是目标函数改变的必然结果——集群优化的目标不是“把每张卡的利用率拉到99%”而是“把每个作业的完成时间压到最短”。需要分清“比例”和“绝对时间”。假设P4、M8bubble比例约27%听着吓人但如果这个作业本身只跑十分钟气泡的绝对时长不到三分钟。与其为了优化bubble而把这个作业从“能立即执行”变成“需要等半小时的资源再开始”不如保持气泡、早开工10分钟。在调度层看来一段排队等待时间里可以执行好几个小作业而流水线里多几个气泡损失的只是单个作业的少量时间。调度目标变了自然会出现“牺牲一点吞吐换早完成”的配置。这个结论对习惯“吞吐为王”的人冲击很大但它确实是共享集群里更真实的优化逻辑。5.3 调度决策的成本必须很低论文里用来求解配置组合的算法我不过多展开细节但有一个设计约束我记得很牢调度器给出的方案必须够快最好在秒级甚至毫秒级完成否则决策本身就成了新的瓶颈。训练作业动辄运行几十分钟到几小时调度决策耗时几十秒是可接受的但如果要在每个step级别做调整决策延迟就不可忽略了。这一点对工程复刻非常重要。我见过一些团队想复现这类JCT调度把最优化模型解得很重结果决策要几分钟作业早就跑完了收益全被决策成本吃掉。PipeSwift的做法提醒我们在调度领域近似解加低延迟往往胜过精确解加高延迟。调度这个词里有个“调”字就是要快慢了就失去意义。6. 把PipeSwift的思路用到你的集群落地检查点6.1 先判断你的集群是不是JCT敏感型不是所有集群都需要JCT优化。如果你的集群长期只有几个大任务在跑用户不在乎排队只在乎单任务性能那直接优化流水线bubble就好但如果你的集群经常有几十个不同类型的训练作业来回提交用户会频繁问“什么时候能跑完”那JCT优化就有价值。一个最简单的判断方式是给所有作业打点记录两段时间排队时间和执行时间。如果队列里小作业的排队时长占JCT的50%以上问题的核心已经不在流水线内部而在调度层。先做这个统计比上来就谈换什么调度策略更靠谱。6.2 落地时三个容易翻车的坑第一坑是把调度粒度调得太细。流水线作业变更stage划分意味着模型权重重新切分、通信拓扑重建代价非常大。调度决策不能像CPU进程切换那样频繁实际工程里按作业粒度决策、运行中尽量避免大幅调整才比较稳妥。第二坑是冷启动开销。为了一句“短作业优先”把大作业暂停再恢复模型重新加载和数据重新读取的时间可能比省出来的排队时间还长。在做任何抢占式设计前先量化切换代价压测一次“暂停-恢复”完整流程的时间再决定要不要做这个功能。第三坑是公平性幻觉。平均JCT下降25%听起来特别漂亮但也要看分布。长作业的P99完成时间有没有恶化有没有作业长时间饿死这两个指标必须在优化目标里同时出现否则上线后你在用户群里会收到大量投诉。我在实际项目里踩过这个坑平均JCT很好看打开分布一看大作业尾部被拉长了几倍最后还是回滚了。6.3 我自己的实践原则先看队列再动流水线最后分享一点个人经验。我最早接触这类JCT调度时习惯性地把精力花在改进流水线并行schedule上结果发现排队问题的收益远大于微调bubble的收益。后来我在集群里加了一条监控每个作业的“排队时间比例”按小时出曲线。当这个比例普遍超过60%我就知道该把注意力放到调度器上了等调度稳定后再回头优化流水线内部参数收益才显现出来。这个“先归因再优化”的思路恰恰也是PipeSwift论文给我的最大启发JCT是一个端到端指标不要假设瓶颈只出现在某一个环节里。在排队时间长的时候苦心优化吞吐是白费力气在执行时间长的阶段只改排队策略也是隔靴搔痒。至少对我来说PipeSwift最大的价值就是别只守着流水线内部那点bubble抬头看看队列常常能捡回更大的收益。把这两个维度打通才是JCT时代真正要做的事。
企业数字化 ERP 产品动态
相关推荐
Paperclip:React与AI Agent的工程化协调层 1. 这不是回形针,是AI时代前端工程师的“工程化回形针”——Paperclip到底是什么你搜“paperclip”,第一反应可能是办公桌抽屉里那个弯弯扭扭的金属小物件。但最近半年,在Node.js和React开发者圈子里,“paperclip”这个词频繁出现… · 2026/11/3 21:23:34
云监控核心技术:动态环境下的智能监控实践 1. 云监控的现状与挑战云计算的普及让企业IT基础设施发生了翻天覆地的变化,但随之而来的监控难题却让很多运维团队头疼不已。我经历过从传统IDC到混合云再到多云环境的完整迁移过程,深知云监控与传统监控的根本差异。云环境的动态性是最突出的特点。在传… · 2026/11/3 21:23:34
MySQL 8.0安装指南与性能优化实践 1. 初识MySQL:为什么它成为开发者首选?第一次接触MySQL是在2008年,当时我需要为一个校园网站搭建后台数据库。经过对比SQL Server、Oracle等商业数据库后,我最终选择了这个当时还不太起眼的开源数据库。十几年过去,MyS… · 2026/11/3 21:23:34
AI工程基座构建:Python+TypeScript+Rust+Julia协同实践 1. 为什么“从零构建AI工程体系”不是口号,而是当下最真实的生存需求最近帮三个不同行业的团队做技术栈复盘,发现一个扎心的共性:他们都有“AI项目”,但没有“AI工程”。有人用Jupyter跑通了一个LSTM预测模型,部署时卡… · 2026/11/3 22:14:06
从零开始AI工程:从空文件夹到稳定在线服务的完整链路 这个项目标题我相信不少人都刷到过,ai-engineering-from-scratch,字面意思就是“从零开始做AI工程”。作为一个常年在一线带团队搞AI落地的人,我说句实话:这类名字最容易让人产生两种极端错觉。一种是觉得自己得先把深度学习论文全… · 2026/11/3 22:14:06
把“我早知道”变成决策力:后见之明偏差与复盘工具 凌晨三点,你盯着屏幕里那场足球决赛,对手在最后十分钟连进两球完成逆转。你一拍大腿:“我就知道,换人换得太晚了,教练早该调整!”过了几天你打开基金账户,看到三个月前买的那只股票跌了三成&… · 2026/11/3 22:14:06
嵌入式AI协同开发工作流:从硬件约束到模型验证 1. 项目概述:这不是一次简单的“跑通模型”,而是一场嵌入式与AI的深度协同作战 “嵌入式AI协同开发实战:从约束到验证的完整工作流”——这个标题里没有一个虚词。它说的不是“在嵌入式设备上部署一个现成模型”,也不是“用AI给单… · 2026/11/3 22:14:06
ITSM现代化转型:技术支柱与智能化演进路径 1. ITSM现代化转型的核心驱动力十年前我第一次接触IT服务管理(ITSM)系统时,它还是个基于ITIL框架的工单追踪工具。如今在云计算和AI技术的推动下,ITSM已经演变为企业数字化转型的核心中枢。这种演进不是简单的技术升级,而是服务理念和管理模式… · 2026/11/3 22:14:06
反向拆解能力:从结果反推根因的工程思维方式 1. 为什么我觉得"会拆"比"会装"更难,也更值钱先把话撂这儿:我见过太多能把功能写出来的人,但真正遇到线上诡异问题时,能顺着调用栈一路摸到根因的人,十个里面挑不出两三个。这个差距,就… · 2026/11/3 22:13:06
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46