← All articles

Implementing the SPEC test executor on HarmonyOS

The compute code of a SPEC workload is only one part of the test system. Once it lives inside a HarmonyOS app, inputs still have to be prepared, an execution environment set up, lifecycles controlled, time measured, output checked, and all of this linked into a result that can be re-examined. In our SPEC CPU2017 and CPU2026 apps these duties sit in the executor: ArkTS manages tasks, the native layer runs the workload, and the validation layer decides whether a result may count.

This article has no English version yet; the original Chinese text follows. Chart labels are in Chinese as well.

Contents
  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 测试程序、输入数据与参考输出仍按原许可使用。本文配套只提供执行器说明、独立计时示例和脱敏结果数据,不包含这些测试介质。

Comments

Discussion welcome, no login needed. Comments show up right away; abuse and ads get removed.

Comments

    Loading…