SPEC 测试执行器的鸿蒙实现
SPEC 工作负载的计算代码只是测试系统的一部分。把它放进鸿蒙应用以后,还需要准备输入、建立执行环境、控制生命周期、测量耗时、检查输出,并把这些信息关联成一条可以复查的结果。我们在 SPEC CPU2017 与 CPU2026 的应用中,将这些职责放在执行器里:ArkTS 管理任务,原生层执行负载,校验层决定结果是否可以参与统计。
目录
SPEC 工作负载的计算代码只是测试系统的一部分。把它放进鸿蒙应用以后,还需要准备输入、建立执行环境、控制生命周期、测量耗时、检查输出,并把这些信息关联成一条可以复查的结果。我们在 SPEC CPU2017 与 CPU2026 的应用中,将这些职责放在执行器里:ArkTS 管理任务,原生层执行负载,校验层决定结果是否可以参与统计。
本文围绕单份工作负载的执行展开。两代应用都把测试程序编译成共享库,通过 main 入口运行;每次原生调用由独立的 fork 子进程承载。CPU2026 在此基础上使用数据清单描述输入、命令和校验规则,并将装载时间、计分时间、诊断时间分别记录。下面以 CPU2026 的完整流程为主,同时说明 CPU2017 的实际差异。
从测试选择到结果归档
一次测试按准备、运行、校验和归档推进。界面提交的配置包括测试项目、数据集、核心、轮次和冷却间隔。服务层先保存配置快照,再为每个项目、每一轮建立工作目录。原生计算通过 TaskPool 调用,不阻塞界面线程。
SPEC 执行器 · 一轮任务的生命周期
同一项目可含多条主命令;每轮重新准备输入,主命令耗时求和,输出通过后才纳入有效轮次
准备、冷却、输出比较与归档位于主命令计时之外;取消和错误转入清理并保留失败状态。
文字版
| 步骤 | 说明 | 去向 |
|---|---|---|
| 配置快照 | 项目 / 数据集 / 轮次 | 准备输入 |
| 准备输入 | 新目录 / 大小 / 空间检查 | 原生执行 |
| 原生执行 | fork / 装载 / main | 回收与清理 |
| 回收与清理 | 退出码 / 计时 / 状态恢复 | 输出校验 |
| 输出校验 | 比较器 / specdiff | 结果归档 |
| 结果归档 | 轮次 / 比率 / 原始字段 |
流程中的三个对象需要分清:一个测试项目可以包含多条主命令;一次项目迭代需要完成它规定的全部主命令;用户选择的轮次,则要求把这组命令重新执行相应次数。项目耗时首先是同一迭代内主命令耗时之和,再按轮次策略选取代表值。辅助比较器和输出比较都不加入这个和。
例如 CPU2026 的 SQLite ref 数据集包含 main、cte、fp 三个子任务。它们共用本轮新解压的工作目录,但各自有独立的标准输出和错误日志。清单把调用参数保留为数组,不经过 shell 再解释引号、空格或重定向。
下面是其中一条主命令的描述形态。它是执行参数,不包含测试程序或输入数据;接入执行器时,先由清单校验决定允许加载哪个库、访问哪个工作目录。
{
"library": "lib708.sqlite_r.so",
"executable": "sqlite_r",
"args": ["--memdb", "--size", "2000", "--testset", "main", "--verify"],
"cwd": ".",
"stdin": "",
"stdout": "sqlite_r.main.out",
"stderr": "sqlite_r.main.err"
}
输入准备与执行环境
CPU2026 把测试元数据与数据包分开。元数据记录执行入口、参数、数据集、参考时间和比较选项;数据包提供运行输入与参考输出。应用每轮使用新目录,检查数据包大小和可用空间,再解压输入。空间预检查包括压缩包、解压内容及 256 MiB 余量;之后每次文件操作仍单独检查错误。
执行前还要检查结果文件尚不存在、参考输出已经就位。这样,某次负载提前退出时,执行器不会把上一次留下的正确输出误当作本轮结果。路径与库名也先经过检查:拒绝越出工作目录的相对路径、无效库名,以及输入输出指向同一文件的配置。
同一轮内的多条命令按清单顺序执行,不在命令之间重新解压或更换目录。某些负载依赖连续的文件状态,执行器保持这种关系。项目间冷却和项内主命令间冷却属于调度阶段,不写进主命令耗时。数据准备、冷却、运行和比较分别显示进度,用户能够知道程序是在计算,还是在为下一项准备环境。
用共享库入口运行原生负载
两代实现都保留原生程序的入口和参数形式,但入口来自 HAP 内打包的共享库。NAPI 桥接解析参数,组织 argc/argv,再进入原生执行路径。这里没有启动 shell,也没有把工作目录中的文件当作桌面可执行程序运行。
共享库里的 C/C++ 全局对象会在装载时运行构造函数;不少负载会直接调用 exit、保留大量分配或修改进程状态。独立子进程给这些行为一个明确的结束边界:负载退出以后,由系统回收该进程的地址空间,父进程继续处理结果和界面。
两代实现的装载位置有所不同。
| 环节 | CPU2017 | CPU2026 |
|---|---|---|
| 动态装载 | 父进程先 dlopen 并取得 main | fork 后由子进程装载并取得 main |
| 库构造函数 | 在装载时进入父进程 | 留在本次子进程内 |
| 入口执行 | 子进程切换栈后调用入口 | 子进程完成装载、分配器检查和调度准备后调用入口包装器 |
| 库生命周期 | 父进程在清理时关闭句柄 | 子进程退出时整体回收,不提前卸载退出回调仍在引用的库 |
| 结果返回 | 返回耗时或失败值,ArkTS 随后校验 | 返回结构化 JSON,保留退出码、计时字段、错误与清理状态 |
两代应用都在 SGU-AL10 上用这条 fork 路径完成了实际运行:进程创建、动态装载、文件访问和回收都已走通。
CPU2026 的父进程还会在准备阶段建立共享记录区。子进程把开始、结束、装载、错误阶段等信息写入其中,父进程在子进程退出后整理结果。发生装载失败、入口缺失或重定向失败时,记录指出失败阶段,不会返回一个看起来有效的极短耗时。
大栈和入口签名
部分计算程序需要很大的栈空间。执行器为单次负载预留 1 GiB 的栈地址范围,并通过 AArch64 汇编切换栈指针,再调用入口。CPU2017 使用对齐分配,CPU2026 使用匿名映射并检查映射是否成功。这个容量是预留的地址范围,物理页按负载实际的栈深度提交。
栈顶遵守 16 字节对齐。入口返回后,汇编转入 fflush 与 exit,不回到已经更换栈的普通调用者。CPU2026 的包装器也处理入口直接退出的情况,因此会保留“main 返回”与“退出观察器”两种结束来源。
入口的函数类型有三种。CPU2026 按已知负载区分二参数入口、只读参数入口和带 envp 的三参数入口,桥接层的类型转换与实际构建出的符号一一对应。
这些细节放在原生执行器内部。ArkTS 只提交经过检查的命令描述,并接收结构化结果,不涉及栈切换和 ABI 适配。
计时边界必须随结果保存
CPU2017 在 fork 前取得单调时钟,等待子进程结束并停止维护线程后再次读时钟。共享库装载和栈分配位于起点之前;子进程初始化、负载执行、退出及父进程观察到结束的等待包含在计时内。
CPU2026 同时记录三个观察量:
rawProcessSeconds:父进程用CLOCK_MONOTONIC测得的从 fork 前到回收完成的经过时间。loadSeconds:子进程内完整的dlopen/dlsym区间。mainSeconds:入口包装器记录的诊断区间。
用于汇总主命令的 seconds 按下面的关系计算:
计分耗时 = fork 到回收的经过时间 − 已完成的子进程装载区间。
因此 seconds 比 main 诊断区间长:扣除装载以后,差值是子进程其余初始化、结束处理和父进程回收观察的时间。启用单核调度会话时,父进程以 200 ms 间隔维护和检查子进程,这个观察节奏也计入结束边界。
下面的 C++ 示例只提取计时算术及时间顺序检查,用于理解结果字段;进程回收、退出状态和共享记录检查在执行器里完成。对应的独立文件位于 timing.cpp。
std::optional<double> ScoredSeconds(
uint64_t processStart, uint64_t processEnd,
uint64_t loadStart, uint64_t loadEnd, bool loadCompleted)
{
if (!loadCompleted || processStart == 0 ||
processEnd < processStart || loadStart < processStart ||
loadEnd < loadStart || loadEnd > processEnd) return std::nullopt;
const uint64_t raw = processEnd - processStart;
const uint64_t load = loadEnd - loadStart;
if (raw <= load) return std::nullopt;
return static_cast<double>(raw - load) / 1e9;
}
遇到负载直接 exit(0)、结束观察器没有给出时间时,CPU2026 用父进程回收时刻作为 mainSeconds 的上界,并把 mainSecondsIsUpperBound 记为 true;这只发生在已有有效开始时间且正常退出的情况下。构造阶段提前退出、时钟失败或非零退出直接判为失败。
输出正确以后,时间才有意义
进程返回零只说明它采用了正常退出码。测试还需要检查输出是否符合规定。
CPU2017 根据项目配置比较标准输出、结果文件,或先运行专用比较器再检查比较器输出。浮点项目带有各自的容差配置。CPU2026 则把主命令、辅助比较命令和 specdiff 规则分开记录,保留每次比较的文件、选项、差异数与结果。
CPU2026 的比较实现按相应规则处理绝对容差、相对容差、允许差异数量、空白、二进制比较及特殊浮点值等情况。容差来自负载清单,不随某次输出调整。辅助比较器运行失败和文件内容不一致分别报告,便于区分执行问题与数值问题。
一次迭代只有在所有主命令成功、所有比较完成后,才把主命令耗时总和加入有效轮次。test 数据集用于验证执行与输出路径;只有 ref 数据集具备有效参考时间时才生成比率。
CPU2026 支持一至三轮。选取策略是将有效时间排序,取下标 floor(n/2):一轮取自身,三轮取中位数,两轮取较慢的一轮,而不是取平均。下面是独立的 JavaScript 形式,完整文件包含输入检查和比率计算:measurement.mjs。
export function selectMeasuredTime(times, expectedCount) {
if (!Number.isInteger(expectedCount) || expectedCount < 1 || expectedCount > 3 ||
times.length !== expectedCount ||
times.some(value => !Number.isFinite(value) || value <= 0)) {
throw new Error('Incomplete or invalid measurements');
}
return [...times].sort((a, b) => a - b)[Math.floor(times.length / 2)];
}
单项比率为“参考时间 ÷ 有效测量时间”。应用对已通过项目的正比率取几何平均,并同时显示参与的项目;只运行部分项目时,这个数字是该子集的汇总。
一轮 SQLite ref 实测怎样组成结果
下面展示 SGU-AL10 上一轮 CPU2026 708.sqlite_r 的记录。运行选择 CPU12、ref 数据集、一轮,项内主命令间配置 5 秒冷却;三条命令均正常退出,三个输出文件全部校验通过。
| 主命令 | fork→回收 / s | 装载 / ms | main 诊断 / s | 计分耗时 / s |
|---|---|---|---|---|
| main | 148.468480 | 2.731406 | 144.687739 | 148.465748 |
| cte | 29.305283 | 2.635781 | 25.501698 | 29.302647 |
| fp | 64.169066 | 2.492969 | 60.331029 | 64.166573 |
SPEC CPU2026 · SQLite ref 三条主命令
SGU-AL10 · 2026-09-19 · 一轮通过输出校验的记录 · 请求 CPU12
装载已扣除;浅色差值是子进程初始化、结束处理与父进程回收的时间。三命令合计 241.935 s,参考 528 s,比率 2.182;3 个输出文件校验通过。
数据表
| 类别 | main 诊断区间 / s | 其余计分区间(计分耗时 − main 诊断耗时) / s |
|---|---|---|
| main(第 1 条主命令) | 144.688 s | 3.778 |
| cte(第 2 条主命令) | 25.502 s | 3.801 |
| fp(第 3 条主命令) | 60.331 s | 3.836 |
图中浅色部分是计分耗时与 main 诊断耗时的差值,也就是子进程其余初始化、结束处理和父进程回收观察的时间。装载时间已经从计分耗时扣除,没有再加入图中的条形。
三条主命令的计分耗时相加得到 241.934967969 秒。参考时间为 528 秒,于是该项比率为 2.182404654。项目间和命令间冷却、输出比较及界面停留时间没有混入这个总和。正文配套的 JSON 与 CSV 保留未四舍五入的数值,网站直接据此生成图表。
这组数据来自一轮已完成的运行,用于说明执行器字段及汇总关系。
编译配置与执行结果一起管理
执行器使用什么编译器,不等于每个工作负载使用什么编译器。C/C++ 与 Fortran 的编译来源分别记录,SDK 提供的 ABI、CRT 和运行库也与负载编译器区分。CPU2017 的构建配置对严格 LLVM14 路径设置了独立的 Fortran 工具链检查,避免把不同来源合并成一个编译器标签。
CPU2026 的原生制品清单记录 LLVM 22.1.3、-O3、full LTO 和静态 jemalloc 5.3.0。包内有 26 项测试对应的 30 个执行入口、31 个原生库,以及 52 个数据包。入口数量多于项目数量,因为一些项目需要多个程序或辅助比较器。
jemalloc 的使用还落实到运行前检查:子进程装载后确认分配器归属,并关闭和回读其后台线程设置,再进入负载。这样,构建清单中的分配器选择与运行时路径对应。HAP 核验则检查库、数据、注册表和内容指纹,避免只更新界面而误用另一组原生制品。
这些配置随结果一起保存,作为成绩的测试条件。
取消与清理也属于执行器
用户点击停止以后,服务层先记录取消状态,再通知原生层结束本次子进程。CPU2026 将活动 PID 的发布、回收和失效处理放进受互斥保护的流程,结束后不再向过期 PID 发送信号。准备阶段收到的取消也会被保留,不会因为子进程尚未创建而丢失。
子进程设置父进程死亡保护,并在设置后重新检查父 PID;父进程退出时,子进程随之结束。正常结束或取消后,父进程都要完成回收、停止维护并检查调度清理结果。恢复失败会返回错误,后续任务不会把它当成一轮正常完成。
因此,执行器保存的不只是一个秒数,还包括数据集、命令、退出状态、输出校验、计时口径、编译配置和清理结果。读者复查时可以沿着“输入与参数 → 实际执行 → 正确性 → 时间汇总”重新走一遍;界面上的结果也就有了明确的计算过程和适用范围。
SPEC 测试程序、输入数据与参考输出仍按原许可使用。本文配套只提供执行器说明、独立计时示例和脱敏结果数据,不包含这些测试介质。
评论
欢迎讨论,不用登录。评论会直接显示;骂人和广告会被删掉。
管理
评论
加载中……