Computing Systems Optimization
导言
我的长期职业目标可以收束为一句话:理解软件计算逻辑,把它映射到计算、通信、存储和容量受限的硬件上,通过拆分、掩盖、流水和协同设计提高有效利用率;再把这套理解变成可扩展的性能模型、硬件建议与团队分工。
这不是“会调几个 Kernel”或“熟悉某个训练框架”的目标。我要培养的是从工作负载、系统、软件到硬件的完整判断力:知道数据从哪里来、何时产生、在哪里停留、被谁消费、为什么等待,以及增加哪一种资源才真正缩短端到端关键路径。
目标不是一项技术¶
此前的 AI Technology Value Stack 回答了“为什么选择昂贵、可测、可复用的 AI 系统瓶颈”;AI Optimization Stack 回答了“模型、训练、推理、运行时、Kernel、通信与硬件分别控制什么”。本文进一步回答:我要把这些层培养成怎样的一项长期能力,以及怎样带领团队共同完成它。
表面目标是提高硬件利用率,更准确的目标却是:
在正确性、模型质量、服务 SLO、功耗、可靠性与成本约束下,提高端到端关键路径上的有效工作比例。
这一定义排除了三个危险捷径:
- 忙不等于有效。 设备可以因为重试、冗余搬运、错误的 Batch、无效 Padding 或拥塞一直忙,但单位成本产出的有效结果反而更少。
- 局部满不等于系统快。 一个 Kernel 达到高算力利用率,仍可能被排队、通信、Host 调度或流水空泡吞掉端到端收益。
- 容量不是速率。 显存或内存容量决定任务能不能放下;带宽决定数据多久搬完。二者需要分别建模。
因此,我要长期拥有五种相互闭环的能力:
| 能力 | 要回答的问题 | 可验证产物 |
|---|---|---|
| 工作负载理解 | 软件真正计算什么,哪些语义不能改变? | 计算图、状态图、输入输出契约 |
| 统一性能建模 | 计算、访存、通信、容量和空泡如何共同决定上限? | 资源账本、时序模型、预测区间 |
| 优化实现 | 应拆分、重排、融合、掩盖还是改变并行策略? | 代码、Microbenchmark、端到端 A/B |
| 硬件反馈 | 哪个硬件短板的边际改善最值钱? | 工作负载分布、敏感性与规格建议 |
| 技术组织 | 怎样让多人并行工作而不丢失系统最优? | 模块合同、责任边界、集成验收 |
优化的统一对象¶
四本账¶
可扩展的模型不应从某个 Profiler 指标开始,而应先维护四本相互连接的账:
- 逻辑账: 软件的节点、依赖、状态和不可改变的语义。它回答“先做什么、后做什么,哪些分支可以并行”。
- 资源账: 每个节点需要多少计算、设备内存流量、网络流量、内存/显存容量和工作空间。它回答“消耗了什么”。
- 时序账: 每个对象何时产生、最后一次被谁使用、何时释放;每个引擎何时工作、等待或争用。它回答“为什么出现空泡和峰值”。
- 证据账: 预测值、实测值、环境、误差与边界。它回答“这项判断在什么条件下成立”。
只有逻辑账,没有资源量,就只能画流程图;只有资源账,没有时序,就会把可重叠的时间机械相加;只有时序,没有对象生命周期,就解释不了显存峰值;没有证据账,模型再精细也只是故事。
资源—图—时序模型¶
我把这套可复用机制称为 资源—图—时序模型。概念层的目标是提高有效工作;机制层把工作负载变成图、资源需求和可执行时序;它不是某个框架补丁,每个项目仍需把模型实例化为真实的算子、Collective、Buffer、Stream、队列和硬件拓扑。
对象与公式¶
公式之前先固定核心对象,避免同一个“带宽”在规格、Microbenchmark 和实际任务之间偷换含义:
| 对象 | 生产者 | 单位或形态 | 消费者 | 生命周期与状态 |
|---|---|---|---|---|
| 工作负载图 \(G=(V,E)\) | 语义与依赖分解 | 节点和有向边 | 调度器、模块 Owner | 持久、版本化模型 |
| 节点需求 \(F_i,B_i^{mem},B_i^{net}\) | 计算量、数据流与通信分析 | operations、bytes | 速率下界与资源账本 | 每组 Shape/精度/拓扑维护 |
| 活跃对象 \(M_j(t)\) | 生产算子与状态机 | 随时间变化的 bytes | 峰值容量模型与未来消费者 | 物理对象,最后消费后才可释放 |
| 可达上限 \(P_i^{att},BW_i^{att},NW_i^{att}\) | 受控 Microbenchmark | operations/s、bytes/s | 节点下界 | 硬件、Shape、访问和拓扑参数 |
| 调度 \(S\) | 依赖与资源约束求解 | 开始、结束、引擎、Buffer | makespan 与生命周期 | 版本化实验产物 |
| 实测 \(Y\) 与残差 \(\epsilon\) | Profiler 和端到端 Benchmark | Trace、时间、bytes、计数器 | 模型校准 | 实测不可改,模型可更新 |
设工作负载图为 \(G=(V,E)\)。节点 \(v_i\) 表示一个计算或有状态阶段,边 \(e_{ij}\) 表示数据、控制或所有权依赖。对节点 \(i\) 记录:
- \(F_i\):有效计算量,单位为 operations;
- \(B_i^{mem}\):设备内存层级之间的有效流量,单位为 bytes;
- \(B_i^{net}\):设备或节点之间的通信量,单位为 bytes;
- \(M_j(t)\):对象 \(j\) 在时刻 \(t\) 仍存活的容量,单位为 bytes。
再用同一 Shape、数据类型、访问模式和拓扑下的可达上限 \(P_i^{att}\)、\(BW_i^{att}\)、\(NW_i^{att}\) 计算一个速率下界:
这个式子只回答“最快不能低于多少”,不直接预测真实耗时。真实节点时间 \(T_i\) 还要加入启动、同步、Shape 尾部、资源占用、协议和运行时校准;系统时间也不是 \(\sum_i T_i\),而是依赖图与资源约束下的 makespan,即最后一个必要输出完成的时间。
容量则是另一条约束:
其中 live(t) 是时刻 \(t\) 仍会被未来计算消费的对象集合。一个对象大小不变,只要提前结束生命周期,也可能降低峰值;反过来,逻辑上已经“不再使用”的 View 仍引用底层 Storage 时,显存并不会释放。
在一个测量窗口内,资源 \(r\) 的有效利用率可写成:
\(D_r^{useful}\) 是产生最终有效结果所必需的需求,\(C_r^{att}\) 是实际分配给该工作负载的可达能力,而不是整机规格表峰值。对稳态流水,Little 的排队关系给出 \(L=\lambda W\):平均在途任务数 \(L\) 等于吞吐 \(\lambda\) 乘平均停留时间 \(W\)。它可以检查并发量是否足以填满流水,但只有在长期平均有限、系统近似稳定时才适用。3
完整伪代码¶
完整的教学伪代码如下。它不隐藏分解、计量、调度、校准和回写步骤:
def analyze_system(workload, hardware, baseline_measurement):
graph = build_dependency_graph(workload.semantic_steps)
validate_semantic_equivalence(graph, workload.correctness_contract)
node_models = {}
live_intervals = []
for node in topological_order(graph):
compute_ops = count_useful_operations(node)
memory_bytes = count_device_memory_traffic(node)
network_bytes = count_network_traffic(node, hardware.topology)
object_intervals = trace_object_lifetimes(node)
attainable_compute = microbenchmark_compute(
hardware, node.shape, node.dtype, node.instruction_mix
)
attainable_memory = microbenchmark_memory(
hardware, node.shape, node.access_pattern
)
attainable_network = microbenchmark_network(
hardware.topology, node.message_sizes, node.collective
)
rate_floor = max(
compute_ops / attainable_compute,
memory_bytes / attainable_memory,
network_bytes / attainable_network,
)
calibrated_duration = calibrate_node_duration(
rate_floor,
node.launch_count,
node.synchronization_points,
node.tail_shape,
baseline_measurement,
)
node_models[node.id] = {
"duration": calibrated_duration,
"compute_ops": compute_ops,
"memory_bytes": memory_bytes,
"network_bytes": network_bytes,
}
live_intervals.extend(object_intervals)
schedule = place_nodes_with_dependencies_and_resource_limits(
graph, node_models, hardware.engines, hardware.capacity
)
predicted_makespan = finish_time_of_required_output(schedule)
predicted_peak_memory = maximum_live_bytes(
live_intervals, schedule, hardware.allocator_margin
)
residual = compare_prediction_with_measurement(
predicted_makespan,
predicted_peak_memory,
baseline_measurement,
)
residual_causes = classify_residual(
residual,
graph,
schedule,
baseline_measurement.trace,
baseline_measurement.hardware_state,
)
updated_model = update_only_supported_assumptions(
node_models, residual_causes
)
return updated_model, schedule, residual_causes
对象完整性审计的标准是:图上每个节点和边都能回到输入、输出或状态;公式中的每个 demand 都有计量方法;每个物理对象都有生产者、最后消费者与释放时刻;每个可达上限都带 Shape、精度、拓扑和环境。没有来源的对象不能靠一个“其他开销”长期隐藏。
五视图与边界¶
读这张图时,不能只看一条箭头:A 面板验证是否真的减少空闲;B 面板保留“新代价”;C 面板要求实测残差回写;D 面板检查等待与释放位置;E 面板确保每个资源数字都有数据对象来源。
效果契约
这套模型直接针对的是调度空泡、错误资源归因和不可解释的容量峰值。它不会自动带来性能收益;新增成本包括计量、Profiler、Microbenchmark、模型维护和跨模块同步。只有同语义端到端 A/B 证明 makespan、成本或 SLO 改善后,才能报告数值收益。
适用边界
这套方法要求工作负载语义可固定、关键对象可观测、硬件环境可记录。对强非平稳在线流量,应建模分布、分位数和状态转移,不能只用长期平均;对不可观测的固件或闭源运行时,只能给残差区间和反事实实验,不能伪造内部因果链。
模型如何逐层收敛¶
全局先看关键路径¶
全局模型关注的不是每个模块有多快,而是总资源如何分配、哪个依赖链决定完成时间、哪些设备在何时出现空泡。至少要同时保留:
- 依赖关键路径: 不能通过增加并行度消除的前后关系;
- 资源关键路径: 逻辑可并行,但争用同一计算单元、HBM、网络或 Host 线程;
- 容量关键路径: 某组对象必须同时存活,迫使 Chunk、Offload 或重计算;
- 队列关键路径: 请求到达、Batch 形成、抢占和长尾 Shape 造成的等待;
- 功耗关键路径: 计算、HBM 与互连并发后触发功耗墙或降频。
这一步的产物应是一张全局时序图和资源热力图。若端到端时间由通信后的 Barrier 决定,把非关键分支上的 Kernel 再优化 20% 也不会产生相同比例收益。Amdahl 早已指出,序列或 housekeeping 部分会限制并行加速;现代系统只是把“序列部分”扩展成同步、调度、数据准备与尾部等待。2
模块再看对象流¶
每个模块都要回答一个相同的问题模板:
| 视图 | 必答问题 |
|---|---|
| 计算流程图 | 做了哪些 Projection、MatMul、Reduce、Collective 或状态更新? |
| 时序图 | 输入到达多久后开始,多久后产生第一批与全部输出? |
| 逻辑图 | 哪些依赖必须保留,哪些分支可以拆开或重排? |
| 数据量图 | 输入、输出、中间对象、网络消息与工作空间各有多少 bytes? |
| 资源上限 | 计算、访存、通信和容量中的哪一项先触顶? |
| 验收图 | Microbenchmark 改变了哪个物理量,端到端是否进入关键路径? |
模块模型不是一次性 Excel。它应以 Shape、数据类型、并行拓扑、Batch/序列分布和硬件版本为参数;每次实测都留下预测、残差与解释。模型能迁移到第二个模块或第二种硬件,才开始形成长期竞争力。
上限差距不能消失¶
“建模估算与实际的差距”至少要拆成四类:
- 对象误差: 少算了一个 Buffer、重计算、Layout 转换、Host 同步或通信协议。
- 上限误差: 把规格峰值当成了特定 Shape、精度、访问模式和拓扑下的可达上限。
- 调度误差: 假设可以重叠,实际存在依赖、引擎冲突、Buffer 复用、Barrier 或尾部不均衡。
- 环境误差: 频率、功耗、温度、ECC、NUMA、网络拥塞、后台任务和版本变化。
因此,差距不能被彻底“避免”,但可以用五级上限梯子缩小并定位:
| 层级 | 上限来源 | 回答的问题 |
|---|---|---|
| 规格峰值 | 手册中的 FLOPS、带宽、容量 | 物理设计宣称什么? |
| 可达上限 | 同 Shape/精度/访问/拓扑的 Microbenchmark | 当前环境最多能交付什么? |
| Kernel/Collective 实测 | 单个实现的 Profiler | 实现离可达上限多远? |
| 模块实测 | 含调用、转换、同步与工作空间 | 集成开销是多少? |
| 端到端实测 | 真实请求/训练步/任务 | 是否进入关键路径并改善主指标? |
Roofline 论文的核心价值不是精确预测,而是用上界和瓶颈指导下一步。Figure 1 把计算峰值与可持续内存带宽共同画成上界;右图也显示,计算峰值提高而内存带宽不变时,转折点会右移,更多程序可能仍受内存限制。1
论文 Table 4 更重要的证据是:不同 Kernel 在不同机器上靠近的上限并不相同。例如表中 Intel Xeon 的 SpMV 达到 11.1 GB/s,而相邻上限是 11.2 GB/s;IBM Cell 的 SpMV 达到 47.1 GB/s,而相邻上限是 47.6 GB/s。其他行则暴露 FMA、SIMD、Affinity 或 TLP 等更低一层天花板。1
实务中应把每级差距写成残差:
每次只在证据支持时增加模型细节。若新增参数不能改变瓶颈判断、预测区间或设计决策,它只是复杂度,不是理解力。
拆分、掩盖与流水¶
拆分、掩盖和流水不是三个口号,而是对同一时序图的三种物理动作:
| 动作 | 改变什么 | 直接收益 | 新代价与失败条件 |
|---|---|---|---|
| 拆分 | 把大对象或长任务切成可调度 Chunk | 降低局部峰值、增加并行机会、改善尾部 | 更多启动、边界处理、通信与碎片;Chunk 太小会反向变慢 |
| 掩盖 | 让相互独立的计算、通信或搬运同时发生 | 从关键路径隐藏部分等待 | 需要独立引擎、依赖自由、双 Buffer 和足够在途工作;资源争用会让两边都变慢 |
| 流水 | 让不同阶段处理不同批次或 Micro-batch | 填补阶段空闲,提高稳态吞吐 | 启停气泡、阶段不均衡、状态数量、尾部与同步仍然存在 |
CUDA 的异步接口允许 Host、Device、内存传输和设备间操作表达并发,但官方文档也明确指出,实际并发取决于硬件能力,并需要在结果被消费前同步。7 因此,“调用是 async”只能证明提交方式,不能证明 makespan 已经缩短。
一个优化动作只有同时满足下面四个条件才算成立:
- 语义可拆: 依赖、数值和状态允许切分或重排;
- 资源可并: 两条路径没有争用同一个瓶颈引擎、容量或功耗预算;
- 对象可活: Buffer 的生产、消费、复用和释放时机正确;
- 证据可见: Trace 中的空泡缩小,端到端指标在同条件 A/B 中改善。
让模型反向设计硬件¶
感知硬件短板不是看到某个计数器 100%,而是回答一个反事实问题:
如果只增加这一项资源,保持软件语义、其他硬件能力和成本约束不变,端到端目标能改善多少?
可以对可达计算、HBM 带宽、互连带宽、片上容量、显存容量、DMA 并发或特定指令做局部敏感性实验:
\(S_r\) 表示资源 \(r\) 的边际时间价值。实际硬件优先级还要除以面积、功耗、成本和实现风险,并在工作负载分布上求期望,而不是只看一个模型的最好 Case:
这里的 \(S_r\) 应通过多个受控容量点做有限差分和置信区间,而不是把一次模拟结果当作全局线性导数。瓶颈移动后,继续增加同一资源的边际价值通常会下降。
这里的“软件可达性”非常关键。若编译器、运行时、指令、内存一致性或通信接口无法稳定表达目标数据流,再高的硬件峰值也不会被应用使用。Hennessy 与 Patterson 把软件推动架构创新、提升软硬件接口视为新架构机会;这支持双向反馈,却不保证任何定制单元都会商业成功。6
面向硬件团队的建议不应写成“需要更大带宽”,而应包含:
- 代表性工作负载、Shape 与时间分布;
- 当前关键路径和可复现基线;
- 资源增加前后的模拟或原型结果;
- 软件、编译器和运行时所需接口;
- 面积、功耗、容量、可靠性和成本边界;
- 哪些 Case 获益,哪些 Case 不变或退化。
把系统切成团队¶
模块边界¶
团队分工不能先画组织架构,再强迫软件服从。应先从软件图和资源图选择模块边界:
- 状态所有权清楚: 哪个模块创建、更新、持有和释放对象?
- 接口相对稳定: 输入输出与语义稳定,内部实现可以独立变化。
- 验证可以闭环: 模块能独立做正确性、性能和边界测试。
- 资源冲突可描述: 计算、通信、容量和功耗争用能在接口上暴露。
- 并行工作真实存在: 两个团队不必频繁修改同一状态机或等待同一决策。
Parnas 的模块化原则不是按处理步骤机械切函数,而是把可能变化的设计决策隐藏在模块内部,以降低变化传播。4 对性能团队,还要再加一条:把需要联合优化的资源冲突暴露成共同合同,而不是藏进某一方的实现细节。
每个模块至少维护四类合同:
| 合同 | 内容 | 失败示例 |
|---|---|---|
| 功能合同 | 输入、输出、状态、正确性与精度 | 两边对 Padding、顺序或状态语义理解不同 |
| 资源合同 | FLOPs、bytes、容量、工作空间、并发引擎 | 局部优化偷偷增加全局峰值 |
| 时序合同 | 何时 ready、何时完成、同步与释放 | async 接口返回被误当作数据可用 |
| 证据合同 | 基线、环境、指标、回归与回滚 | 只给最好一次数字,无法集成复现 |
责任结构¶
我希望管理的不是一群孤立专家,而是一套共享模型下的模块所有者:
| 角色 | 主要责任 | 不应独占的权力 |
|---|---|---|
| 系统性能负责人 | 定义端到端目标、全局模型、关键路径和模块合同 | 不能替模块所有者写完所有详细实现 |
| 计算模块负责人 | 算子、编译、Tile、融合和计算上限 | 不能忽略数据搬运与端到端占比 |
| 通信模块负责人 | Collective、P2P、拓扑、协议与 Overlap | 不能只报告链路带宽 |
| 内存与运行时负责人 | 对象生命周期、Allocator、队列、调度和 Host 路径 | 不能把容量问题等同于带宽问题 |
| 测量与集成负责人 | Trace、基线、回归、环境控制与跨模块验收 | 不能成为事后测试员 |
| 硬件协同接口人 | 聚合工作负载、敏感性和规格建议 | 不能从单个热点直接承诺硬件收益 |
Conway 在 1968 年指出,组织结构会限制可有效探索的设计方案;任务一旦委派,也会立即产生协调问题。5 因此,团队边界必须跟随系统概念迭代。若新的 Trace 证明两个模块长期共同决定同一关键路径,就应建立联合 Owner、共同基线或重新切分,而不是坚持最初组织图。
技术管理¶
这也澄清了我对“管理”的真实目标:我未必想把主要精力放在人员行政、情绪协调和汇报层级上,但我需要具备技术组织能力。
技术管理不是离开技术,而是:
- 用统一对象和指标消除口径冲突;
- 让全局模型决定优先级,而不是职位或声音大小;
- 给模块 Owner 足够的设计空间,同时守住接口与证据;
- 在局部最优伤害系统最优时及时止损;
- 培养第二层负责人,使模型、实现和判断不依赖一个人。
团队每个迭代按同一个闭环运行:
- 系统负责人冻结口径: 发布当前 Baseline、前三个关键瓶颈、不可改变的语义和端到端验收。
- 模块 Owner 提交反事实: 说明改哪个对象、预期改变哪条资源或生命周期、会新增什么代价。
- 接口双方共同评审: 对跨模块 Buffer、完成事件、Shape 与回滚条件建立共同 Owner,不能把风险推到边界外。
- 测量与集成统一验收: 先验局部物理量,再验端到端关键路径;失败结果同样进入证据账。
- 复盘后重画系统: 更新残差、优先级与模块边界;瓶颈移动时及时停止旧方向。
最终,一名系统负责人不需要掌握每个模块的全部代码,却必须能审查每个模块的对象、数据量、时序、上限、残差与集成影响。团队规模扩展的本质,是让判断模型扩展,而不是让会议数量扩展。
培养路线¶
这条职业路线可以按责任半径逐步扩大:
| 阶段 | 核心任务 | 必须留下的资产 | 晋级证据 |
|---|---|---|---|
| 单模块 | 对一个算子、Collective 或运行时路径建立完整模型 | 对象账本、Microbenchmark、Profiler、边界测试 | 预测能解释主要残差,局部收益可复现 |
| 端到端链路 | 把多个模块放入同一关键路径与容量模型 | 全局时序、峰值账本、同语义 A/B | 能拒绝无端到端价值的局部优化 |
| 跨硬件迁移 | 在 GPU/NPU 或两代硬件上校准同一模型 | 可达上限梯子、迁移规则、失败 Case | 能区分通用机制与硬件补丁 |
| 软硬协同 | 用敏感性与原型提出硬件/接口改进 | 工作负载集、反事实结果、规格合同 | 建议能覆盖一类负载而非单点热点 |
| 团队负责人 | 让多个 Owner 在共享模型下并行交付 | 模块合同、集成节奏、培养与复盘机制 | 团队离开个人微观指挥仍能稳定闭环 |
每个项目结束时,至少沉淀五件东西:
- 工作负载图: 语义、Shape、状态和依赖;
- 资源账本: 计算、访存、通信、容量和功耗;
- 时序与生命周期: 关键路径、空泡、峰值和释放;
- 证据包: Baseline、A/B、Trace、环境与残差;
- 复用模块: 代码、工具、模型、接口、测试或硬件建议。
失败标准¶
下面几种状态说明路线正在偏离:
- 只追计数器: 指标漂亮,但单位成本、端到端时间、质量或 SLO 没有改善。
- 只做局部补丁: 每个新模型都重新适配,无法沉淀第二个 Case 可复用的对象和接口。
- 模型无限变复杂: 参数越来越多,却不能改变瓶颈判断、预测区间或优先级。
- 按组织切软件: 团队边界遮蔽真实依赖,接口处长期出现返工和无人负责的性能损失。
- 从热点跳到硬件: 没有反事实敏感性、覆盖率和面积功耗成本,就提出下一代规格。
- 负责人亲自承包一切: 局部质量依赖一个人,模块 Owner 没有模型与验收责任,团队无法扩展。
最危险的成功
最危险的不是一次优化失败,而是某个局部数字成功后被错误推广:Kernel 快了、通信带宽高了、显存降了,但系统瓶颈已经移动,甚至总成本变差。每次成功都必须重新建模,而不是永久贴上“已优化”标签。
总结¶
我的长期职业目标不是绑定某个模型、框架、芯片或岗位名称,而是建立一项可迁移的系统能力:
从软件逻辑出发,把计算、访存、通信、容量、调度和功耗放进同一资源—图—时序模型;通过拆分、掩盖与流水提高关键路径上的有效硬件利用率;用实测残差收敛模型,用反事实敏感性指导下一代硬件,再把模型转化为可并行、可验收、可培养的团队责任。
这条路线的核心竞争力不是“知道答案”,而是能把复杂系统变成可解释对象,把估算误差变成下一步调查,把局部优化变成端到端证据,把个人理解变成团队共同使用的模型。
参考资料¶
-
Samuel Williams, Andrew Waterman, David Patterson, Roofline: An Insightful Visual Performance Model for Multicore Architectures, Communications of the ACM 52(4), 2009. 本文图表取自 OSTI 公开 PDF。 ↩↩
-
Gene M. Amdahl, Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities, AFIPS Spring Joint Computer Conference, 1967. ↩
-
John D. C. Little, A Proof for the Queuing Formula: L = λW, Operations Research 9(3), 1961. ↩
-
David L. Parnas, On the Criteria To Be Used in Decomposing Systems into Modules, Communications of the ACM 15(12), 1972. ↩
-
Melvin E. Conway, How Do Committees Invent?, Datamation, April 1968. ↩
-
John L. Hennessy, David A. Patterson, A New Golden Age for Computer Architecture, Communications of the ACM 62(2), 2019. ↩
-
NVIDIA, CUDA Programming Guide: Asynchronous Execution and Asynchronous Data Copies, accessed 2026-07-31. ↩



