← 全部文章

SPEC 测试执行器的鸿蒙实现

SPEC 工作负载的计算代码只是测试系统的一部分。把它放进鸿蒙应用以后,还需要准备输入、建立执行环境、控制生命周期、测量耗时、检查输出,并把这些信息关联成一条可以复查的结果。我们在 SPEC CPU2017 与 CPU2026 的应用中,将这些职责放在执行器里:ArkTS 管理任务,原生层执行负载,校验层决定结果是否可以参与统计。

目录
  1. 从测试选择到结果归档
  2. 输入准备与执行环境
  3. 用共享库入口运行原生负载
  4. 大栈和入口签名
  5. 计时边界必须随结果保存
  6. 输出正确以后,时间才有意义
  7. 一轮 SQLite ref 实测怎样组成结果
  8. 编译配置与执行结果一起管理
  9. 取消与清理也属于执行器

SPEC 工作负载的计算代码只是测试系统的一部分。把它放进鸿蒙应用以后,还需要准备输入、建立执行环境、控制生命周期、测量耗时、检查输出,并把这些信息关联成一条可以复查的结果。我们在 SPEC CPU2017 与 CPU2026 的应用中,将这些职责放在执行器里:ArkTS 管理任务,原生层执行负载,校验层决定结果是否可以参与统计。

本文围绕单份工作负载的执行展开。两代应用都把测试程序编译成共享库,通过 main 入口运行;每次原生调用由独立的 fork 子进程承载。CPU2026 在此基础上使用数据清单描述输入、命令和校验规则,并将装载时间、计分时间、诊断时间分别记录。下面以 CPU2026 的完整流程为主,同时说明 CPU2017 的实际差异。

从测试选择到结果归档

一次测试按准备、运行、校验和归档推进。界面提交的配置包括测试项目、数据集、核心、轮次和冷却间隔。服务层先保存配置快照,再为每个项目、每一轮建立工作目录。原生计算通过 TaskPool 调用,不阻塞界面线程。

SPEC 执行器 · 一轮任务的生命周期

同一项目可含多条主命令;每轮重新准备输入,主命令耗时求和,输出通过后才纳入有效轮次

配置快照项目 / 数据集 / 轮次准备输入新目录 / 大小 / 空间检查原生执行fork / 装载 / main回收与清理退出码 / 计时 / 状态恢复输出校验比较器 / specdiff结果归档轮次 / 比率 / 原始字段

准备、冷却、输出比较与归档位于主命令计时之外;取消和错误转入清理并保留失败状态。

文字版
步骤说明去向
配置快照项目 / 数据集 / 轮次准备输入
准备输入新目录 / 大小 / 空间检查原生执行
原生执行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、保留大量分配或修改进程状态。独立子进程给这些行为一个明确的结束边界:负载退出以后,由系统回收该进程的地址空间,父进程继续处理结果和界面。

两代实现的装载位置有所不同。

环节CPU2017CPU2026
动态装载父进程先 dlopen 并取得 mainfork 后由子进程装载并取得 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装载 / msmain 诊断 / s计分耗时 / s
main148.4684802.731406144.687739148.465748
cte29.3052832.63578125.50169829.302647
fp64.1690662.49296960.33102964.166573

SPEC CPU2026 · SQLite ref 三条主命令

SGU-AL10 · 2026-09-19 · 一轮通过输出校验的记录 · 请求 CPU12

020406080100120140160smain第 1 条主命令cte第 2 条主命令fp第 3 条主命令144.688 s25.502 s60.331 s148.466 s29.303 s64.167 s

装载已扣除;浅色差值是子进程初始化、结束处理与父进程回收的时间。三命令合计 241.935 s,参考 528 s,比率 2.182;3 个输出文件校验通过。

数据表
类别main 诊断区间 / s其余计分区间(计分耗时 − main 诊断耗时) / s
main(第 1 条主命令)144.688 s3.778
cte(第 2 条主命令)25.502 s3.801
fp(第 3 条主命令)60.331 s3.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 测试程序、输入数据与参考输出仍按原许可使用。本文配套只提供执行器说明、独立计时示例和脱敏结果数据,不包含这些测试介质。

评论

欢迎讨论,不用登录。评论会直接显示;骂人和广告会被删掉。

评论

    加载中……