← All articles

Single-core scheduling on HarmonyOS: making the core selection reach the compute workload

When running single-core tests on HarmonyOS, the core chosen in the UI has to reach the compute thread through the call chain. This article records how QoS, sched_setattr/uclamp and affinity requests are issued and read back, the runtime maintenance and restore strategy of each benchmark, the build configuration, and measured single-core curves of SPEC CPU2017/2026 and Geekbench 6/7 on the SGU-AL10.

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

我们在鸿蒙上做单核性能测试时,调度方案围绕实际执行计算的线程展开:测试前确定目标核心,运行时维持性能需求,结束后清理线程状态,并把调度回读与结果一起保存。这样,界面上的核心选择才能沿着任务调用链传递到原生负载。

这套方案覆盖 SPEC CPU2017、SPEC CPU2026,以及同一应用内的 Geekbench 6、Geekbench 7 两套引擎。应用层负责测试配置和生命周期,原生层负责执行与线程调度,系统负责最终的频率和资源仲裁。

先交代实测条件:本文曲线中的高性能运行条件由 PC 端 HdcPerfController 开启“高性能 + 监控”提供。PC 端通过 HDC 与设备 SP_daemon 的内部协议提交 -lockfreq 请求;图中的满频或接近满频平台不能归因于基准应用本体自行拉频。基准应用在这个条件下负责选核、计算线程的调度需求和状态恢复。PC 端的指令、保活与屏幕常亮机制另文介绍。

接入点应放在 NAPI 异步工作的执行回调、原生计算线程,或引擎真正承担计算的 worker 入口。OH_QoS_*、sched_setaffinity(0, ...) 等调用作用于调用线程;如果只在 UI 点击回调里设置,随后把计算交给另一个线程,新线程不会自动获得相同的会话管理。下面给出实际使用形态,workload 是应用自己的计算函数:

#include "thread_session.h"
#include <exception>

struct RunResult {
    single_core::SessionReport scheduling;
    std::exception_ptr failure;
    bool completed = false;
    bool schedulingAccepted = false;
    bool cleanupPassed = false;
};

using SchedulingRule = bool (*)(
    const single_core::SessionReport&) noexcept;

RunResult ExecuteOnActualWorker(int targetCpu, void (*workload)(),
                                SchedulingRule acceptScheduling)
{
    RunResult result{};
    if (workload == nullptr || acceptScheduling == nullptr) return result;
    single_core::Options options{};
    options.useQos = true;
    options.requestRealtime = false;
    single_core::ThreadSession session(targetCpu, options);

    result.schedulingAccepted = acceptScheduling(session.Report());
    if (!result.schedulingAccepted) {
        result.cleanupPassed = session.Finish();
        result.scheduling = session.Report();
        return result;
    }

    try {
        workload(); // 应用自己的计时与计算入口
        result.completed = true;
    } catch (...) {
        result.failure = std::current_exception();
    }

    (void)session.ObserveNow(); // 计时结束后只回读,不重新提交请求。
    result.cleanupPassed = session.Finish();
    result.scheduling = session.Report();
    return result;
}

ThreadSession 是随文提供的最小 C++17 封装,负责捕获、请求、回读和恢复;完整代码与接入示例可以放进 Native 模块阅读。acceptScheduling 由实验要求决定:精确单核模式可检查 snapshotComplete && exactAtReadback;SOFT 模式则按目标核心组、有效属性和运行期观测接纳。若还要求 QoS5 或 uclamp,应把对应有效值加入规则。completed 只表示负载完成,cleanupPassed 只表示恢复通过,两者都不能替代调度准入与输出校验。析构仅兜底,显式 Finish() 才能让调用者知道恢复是否成功。示例不自行创建计时器,周期维护另有生命周期管理。

在工程中启用头文件所需的 GNU affinity 扩展,并链接鸿蒙公开 QoS 库:

target_compile_features(entry PRIVATE cxx_std_17)
target_compile_definitions(entry PRIVATE _GNU_SOURCE)
target_link_libraries(entry PUBLIC libqos.so)

公开 QoS C API 从 API 12 起提供。将示例接到 NAPI 时,应由异步任务承载计算和等待过程,保持 ArkUI 线程负责交互。

选核首先依赖运行时拓扑。程序读取逻辑 CPU、兄弟线程和最高频率等信息,识别目标核心组,再构造亲和性请求。以 SGU-AL10 为例,超大核对应 CPU12/13;这里的逻辑编号来自该设备的拓扑,程序不会把它作为所有鸿蒙设备的通用编号。存在硬件线程兄弟关系时,也会按物理核心组解释实际核号。

对于能够获得精确亲和性回读的目标,运行线程请求单 CPU 掩码;对于超大核上的 SOFT 路径,则持续提出性能需求,并结合实际核号与有效掩码重试。请求返回值、掩码回读和实际运行位置分别记录,避免一个“设置成功”状态掩盖后续的线程迁移。

性能需求由几个互补的接口表达。QoS 的 USER_INTERACTIVE 对应等级 5,用于表达高响应需求;nice 调整普通调度优先级;uclamp 使用 0–1024 的归一化范围表达利用率约束。当前高性能请求通常取 uclamp.min = uclamp.max = 1024。这些参数影响系统如何对待线程,实际频率仍由系统负载、功耗和热策略共同决定。

接口用在什么位置为什么使用
OH_QoS_SetThreadQoS实际计算线程进入受管运行范围时向系统表达线程的响应需求,随后用 Get 回读有效等级
sched_setattr保存原属性后,负载主计时开始前请求调度策略、nice 和 uclamp,记录设备接受的回退档位
sched_setaffinity已确定目标逻辑 CPU 的计算线程表达运行位置要求,随后核对有效掩码与实际核号
sched_getcpu / 线程 CPU 时间负载边界及受控采样点观察真正消耗 CPU 的线程落点,解释运行行为

QoS 的设置与恢复需要知道原状态。下面是会话中的基本逻辑:

#include <qos/qos.h>

QoS_Level previous{};
const bool previousKnown =
    OH_QoS_GetThreadQoS(&previous) == 0;

bool changed = false;
if (previousKnown) {
    changed =
        OH_QoS_SetThreadQoS(QOS_USER_INTERACTIVE) == 0;
}

QoS_Level effective{};
const bool interactive = changed &&
    OH_QoS_GetThreadQoS(&effective) == 0 &&
    effective == QOS_USER_INTERACTIVE;

Get 返回 −1 既可能表示尚未注册,也可能表示内部错误,所以不能把它直接当成默认等级 2。通用示例在原值未知时跳过 QoS 修改;应用自己创建的新线程则可以采用显式标记的“未注册推断、Set/Reset 生命周期”。已读取到原等级的线程使用 Set 恢复该等级,避免把原本注册的线程变成未注册状态。

uclamp 通过 sched_attr 的两个字段提交。下面摘出保留调用时当前策略、只请求利用率约束的会话内分支;originalAttr 是通过 sched_getattr 成功捕获的原状态。示例使用已知的 v1 布局和具名标志,完整结构、捕获与兼容性检查在随文头文件中:

constexpr uint64_t kResetOnFork = 1ULL << 0;
constexpr uint64_t kKeepPolicy  = 1ULL << 3;
constexpr uint64_t kKeepParams  = 1ULL << 4;
constexpr uint64_t kUtilMin     = 1ULL << 5;
constexpr uint64_t kUtilMax     = 1ULL << 6;

SchedAttr request = originalAttr;
request.size = sizeof(request);
request.flags = (originalAttr.flags & kResetOnFork) |
    kUtilMin | kUtilMax | kKeepPolicy | kKeepParams;
request.utilMin = 1024;
request.utilMax = 1024;

const int setRc = static_cast<int>(
    syscall(SYS_sched_setattr, 0, &request, 0));

SchedAttr actual{};
actual.size = sizeof(actual);
const int getRc = static_cast<int>(syscall(
    SYS_sched_getattr, 0, &actual, sizeof(actual), 0));

const bool clampVerified = setRc == 0 && getRc == 0 &&
    actual.utilMin == 1024 && actual.utilMax == 1024;

kUtilMin、kUtilMax 告诉内核更新两个 uclamp 字段;kKeepPolicy、kKeepParams 则让这次请求继续使用线程当前策略和参数。这里保留原有 RESET_ON_FORK 位。KEEP_* 是提交请求时的控制标志,恢复完整原属性时要去掉,并显式带上两个 uclamp 更新标志。若设备不支持该结构或请求,示例保留失败状态并降级,不把零初始化字段当成有效回读。

affinity 也需要把请求与回读写在一起。下面的 cpu 必须先通过 0 <= cpu && cpu < CPU_SETSIZE 检查:

cpu_set_t requested{};
CPU_ZERO(&requested);
CPU_SET(cpu, &requested);
const int setRc =
    sched_setaffinity(0, sizeof(requested), &requested);

cpu_set_t effective{};
const int getRc =
    sched_getaffinity(0, sizeof(effective), &effective);
const int actualCpu = sched_getcpu();

const bool exactAtReadback = setRc == 0 && getRc == 0 &&
    CPU_COUNT(&effective) == 1 &&
    CPU_ISSET(cpu, &effective) && actualCpu == cpu;

CPU_SET(0, ...) 选择逻辑 CPU0;sched_setaffinity(0, ...) 的第一个零则表示调用线程。两者不能混淆。exactAtReadback 只表示这一次请求、掩码和实际核号一致;运行过程中仍需在指定边界观察迁移。示例头文件会即时保存失败的 POSIX errno,便于区别权限、目标范围和内核支持问题。

不同基准采用适合自身执行方式的封装:

基准现行单核调度方式运行期间的维护
SPEC CPU2017每条命令在独立子进程运行;请求目标 affinity、调度属性与 uclamp;当前路径不设置 QoS子进程使用标称 10 ms 的信号维护,父侧维持运行需求
SPEC CPU2026ArkTS 经 taskpool 进入 NAPI;每条命令创建子进程;父侧使用 QoS5,子侧建立目标核心和调度属性请求看门狗维护入口线程;Stockfish 搜索线程、Marian 推理线程具有自身调度入口
Geekbench 6在原生 benchmark driver 上建立单核会话,选择 Prime,设置 QoS、调度属性和 affinity 并回读在负载边界重申需求,由运行期维护线程检查实际计算线程
Geekbench 7与 GB6 共用单核调度基础设施,使用独立引擎状态和 Prime 请求通过引擎回调和运行期维护检查线程身份、CPU 时间与核号

调度属性按应用采用明确的回退顺序。SPEC 子进程依次尝试 FIFO 优先级 1 加 uclamp、普通调度加 nice=-20 和 uclamp、FIFO-only;Geekbench 的第三档是保留调用时当前策略和参数,仅请求 uclamp。教学示例采用 Geekbench 式回退,实时请求默认关闭。每一步都保留系统返回和有效值,最终结果采用实际回读解释执行状态。

单核测试仍可能包含控制线程和工作线程,维护必须跟随真正承担计算的 TID。SPEC CPU2026 对 Stockfish 和 Marian 接入了计算线程生命周期:线程进入计算范围时建立自身调度状态,观察器核实进程归属与线程启动时间后,向该 TID 发送定向维护信号,使调度调用在计算线程自身执行。Geekbench 则使用专用维护线程,约每 100 ms 检查 driver 和引擎暴露的实际工作线程。线程启动时间用于识别 TID 复用,CPU 时间增量用于区分计算线程与低负载控制线程。

例如,Stockfish 的接入放在搜索线程生命周期内,而不是创建它的控制线程上:

void Thread::idle_loop()
{
    cpu2026::WorkerThreadScope schedulingScope;
    // 后面继续执行原有搜索线程循环。
}

WorkerThreadScope 是我们的工作线程生命周期适配器。它在进入时注册实际 TID,离开时注销;Marian 只在正式推理线程池使用同样的接入方式。通用示例的 Refresh() 应在同一 owner 线程的非计时边界调用。生产信号看门狗另有注册、身份校验和退出排空协议,不能把含 QoS、NAPI、锁、堆分配或日志的普通函数直接放进信号处理器。

周期维护会产生额外开销,因此它属于测试运行配置的一部分。SPEC 的信号周期和 Geekbench 的维护周期都有固定边界;结束或取消时先停止维护,再完成线程或子进程回收。Geekbench 按 QoS、调度属性、affinity 的顺序恢复捕获状态并回读;SPEC 在命令结束后执行其默认状态清理。清理失败会进入运行状态,影响后续任务接纳。

编译优化也按基准的构建方式分别处理。能够从源码重编译的 SPEC 负载,优化重点在编译器、循环与跨函数优化,以及内存分配路径;Geekbench 的优化重点则在我们编译的桥接和兼容层。

构建对象现行配置优化重点
SPEC CPU2017OHOS Clang/Clang++ 14.0.6、classic flang 14.0.1;-O3 -funroll-loops -march=armv8.2-a+dotprod+fp16;LTO 关闭统一 C/C++ 与 Fortran 工具链,启用循环优化和对应目标指令特性;数学优化按项目处理,比较器采用独立配置
SPEC CPU2026LLVM/Clang/Flang 22.1.3;-O3、full LTO、LLD、静态 jemalloc 5.3.0在更大的编译单元范围内优化调用和数据流;针对鸿蒙动态库加载处理分配器绑定与原生 TLS
Geekbench 6/7 的本地移植层NAPI bridge、Bionic/Musl 兼容层和 shim 使用 -O2、NDEBUG;GB6 使用匹配的 oneTBB 2021.7.0 Release ABI让调试安装包中的桥接与兼容路径也保持优化状态;引擎的计算结构和输入继续由各自原生实现提供

编译参数的作用需要结合负载理解。循环展开可能减少控制开销,也可能增加代码体积;LTO 可以扩大内联和跨函数优化范围;分配器优化更容易影响频繁申请和释放内存的项目。我们把这些配置落实到具体构建对象,再用输出校验约束结果正确性,不给不同基准套用一个统一的优化收益百分比。

在 CMake 中也按 target 配置,避免把测试库、输出比较器和桥接层混在一组全局参数里。下面的 my_* 是接入时需要替换的目标名:

# 从源码构建的 SPEC2026 测试库:编译和最终链接都参与 full LTO。
target_compile_options(my_spec2026_kernel PRIVATE -O3 -flto)
target_link_options(my_spec2026_kernel PRIVATE -flto -fuse-ld=lld)

# 我们自己编译的桥接层:即使打调试安装包,也使用优化后的执行路径。
target_compile_options(my_bridge PRIVATE -O2)
target_compile_definitions(my_bridge PRIVATE NDEBUG)

SPEC2017 的 dotprod、fp16 目标特性只用于已确认支持这些指令的设备。SPEC2026 的静态分配器还需要配套的分配函数族适配、动态库符号绑定和 TLS 配置,不能把一个链接参数当作全部接入步骤。

实测使用 SGU-AL10 上已完成的单核运行记录。下面按软件和具体项目展示结果,SPEC 保留单份 Ref 工作量的耗时与估计比值,Geekbench 保留各自引擎的单核计分。不同基准的分值采用各自标尺。

软件测试项目该次单核结果
SPEC CPU2017505.mcf_r,Ref 单份295.187 秒,估计比值 5.474,输出校验通过
SPEC CPU2017519.lbm_r,Ref 单份185.565 秒,输出校验通过
SPEC CPU2026706.stockfish_r,3 条 Ref 主命令263.089 秒,估计比值 4.789,输出校验通过
SPEC CPU2026772.marian_r,2 条 Ref 主命令297.956 秒,估计比值 5.299,输出校验通过
SPEC CPU2026708.sqlite_r,3 条 Ref 主命令251.834 秒,估计比值 2.097,输出校验通过
Geekbench 6.6.0CPU Single-Core 套件1704 分
Geekbench 7.0.0CPU Single-Core 套件1469 分

鸿蒙单核实测曲线

SGU-AL10 · PC 端高性能监控模式 · 约 1 Hz 整机逐核遥测 · 未平滑、未补点

SPEC CPU2017 | 519.lbm_r · Ref 单份负载175 个原始样本 · 175.6 s 窗口CPU12/13 平均报告频率 · GHz122.752.75 GHz整机逐核使用率 · %050100020406080100120140160SPEC CPU2026 | 706.stockfish_r · classical 位置…73 个原始样本 · 72.8 s 窗口CPU12/13 平均报告频率 · GHz122.752.75 GHz整机逐核使用率 · %050100010203040506070Geekbench 6.6.0 | CPU Single-Core 套件 · 16 个项目274 个原始样本 · 273.7 s 窗口 · 单核套件 1704 分CPU12/13 平均报告频率 · GHz122.752.75 GHz整机逐核使用率 · %050100050100150200250窗口内相对时间 / sGeekbench 7.0.0 | CPU Single-Core 套件 · 16 个项目311 个原始样本 · 311.4 s 窗口 · 单核套件 1469 分CPU12/13 平均报告频率 · GHz122.752.75 GHz整机逐核使用率 · %050100050100150200250300窗口内相对时间 / s

频率为 CPU12/13 同帧平均报告值,虚线为 2.75 GHz;使用率为整机逐逻辑 CPU 百分比,不等于测试 TID 驻留率。

数据表

展开后由页面生成;不运行脚本时请下载上面的数据文件。

每个软件分别画两条纵向关系:上方是 CPU12/13 的平均报告频率,下方是这两个逻辑 CPU 各自的整机使用率。测试均在 PC 端高性能监控会话下记录。SPEC2017 图对应 519.lbm_r;SPEC2026 图对应 Stockfish 的 classical 位置 1–6 子命令;GB6、GB7 图对应完整 CPU Single-Core 套件,包含各自 16 个单核项目及配置的项目间隔。

LBM 窗口包含 175 个样本,目标组报告频率为 1.675–2.695 GHz;Stockfish 该子命令窗口包含 73 个样本,频率为 2.220–2.750 GHz。GB6 单核阶段包含 274 个样本,频率为 2.220–2.750 GHz;GB7 为 311 个样本,频率为 1.925–2.750 GHz。曲线保留了负载执行、逻辑核使用率变化和项目间隔造成的下降,便于观察同一目标核心组在不同负载下的响应。

完整套件的时间跨度较长,再放大 File Compression 项目可以更清楚地看到单个工作负载附近的变化:

File Compression 单核局部曲线

SGU-AL10 · PC 端高性能监控模式 · 约 1 Hz 整机逐核遥测 · 未平滑、未补点

Geekbench 6.6.0 | File Compression · 单核项目窗口24 个原始样本 · 23.7 s 窗口 · 单项 1529 分CPU12/13 平均报告频率 · GHz122.752.75 GHz整机逐核使用率 · %050100048121620窗口内相对时间 / sGeekbench 7.0.0 | File Compression · 单核观测片段14 个原始样本 · 14.3 s 窗口 · 单项 1536 分CPU12/13 平均报告频率 · GHz122.752.75 GHz整机逐核使用率 · %05010002468101214窗口内相对时间 / s

频率为 CPU12/13 同帧平均报告值,虚线为 2.75 GHz;使用率为整机逐逻辑 CPU 百分比,不等于测试 TID 驻留率。

数据表
面板窗口内相对时间 / s平均报告频率 / GHzCPU12 使用率 / %CPU13 使用率 / %
Geekbench 6.6.0 | File Compression · 单核项目窗口0.2 s2.751000
Geekbench 6.6.0 | File Compression · 单核项目窗口1.2 s2.751000
Geekbench 6.6.0 | File Compression · 单核项目窗口2.2 s2.751000
Geekbench 6.6.0 | File Compression · 单核项目窗口3.2 s2.751000
Geekbench 6.6.0 | File Compression · 单核项目窗口4.2 s2.581000
Geekbench 6.6.0 | File Compression · 单核项目窗口5.2 s2.751001
Geekbench 6.6.0 | File Compression · 单核项目窗口6.2 s2.751000
Geekbench 6.6.0 | File Compression · 单核项目窗口7.2 s2.581001
Geekbench 6.6.0 | File Compression · 单核项目窗口8.2 s2.751000
Geekbench 6.6.0 | File Compression · 单核项目窗口9.2 s2.471000
Geekbench 6.6.0 | File Compression · 单核项目窗口10.2 s2.58995.9
Geekbench 6.6.0 | File Compression · 单核项目窗口11.2 s2.75694.9
Geekbench 6.6.0 | File Compression · 单核项目窗口12.2 s2.581100
Geekbench 6.6.0 | File Compression · 单核项目窗口13.2 s2.750100
Geekbench 6.6.0 | File Compression · 单核项目窗口14.2 s2.75041
Geekbench 6.6.0 | File Compression · 单核项目窗口15.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口16.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口17.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口18.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口19.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口20.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口21.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口22.2 s2.7500
Geekbench 6.6.0 | File Compression · 单核项目窗口23.2 s2.7500
Geekbench 7.0.0 | File Compression · 单核观测片段0.6 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段1.5 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段2.6 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段3.5 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段4.6 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段5.6 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段6.6 s2.75990
Geekbench 7.0.0 | File Compression · 单核观测片段7.5 s2.751000
Geekbench 7.0.0 | File Compression · 单核观测片段8.5 s2.751001
Geekbench 7.0.0 | File Compression · 单核观测片段9.6 s2.7556.20
Geekbench 7.0.0 | File Compression · 单核观测片段10.5 s2.7500
Geekbench 7.0.0 | File Compression · 单核观测片段11.5 s2.7500
Geekbench 7.0.0 | File Compression · 单核观测片段12.5 s2.7500
Geekbench 7.0.0 | File Compression · 单核观测片段13.6 s2.7500

GB6 的 File Compression 窗口为 24 个样本,频率 2.470–2.750 GHz,图中保留了尾部项目间隔;GB7 展示从该项目命名起点开始的 14 个观测样本,频率均为 2.750 GHz。后者是观测片段,横轴长度不代表项目精确计分耗时。这两次记录中 File Compression 的单核项目分分别为 1529 和 1536,属于各自引擎的计分结果。

所有曲线使用约 1 Hz 的原始逐核遥测,未做平滑或补点。SPEC2017 图裁去测试窗口两端各 5 秒,Stockfish 图裁去两端各 2 秒;Geekbench 保留完整单核阶段。图中的整机核使用率需要结合测试窗口解释,不能直接当作某个 TID 的连续驻留率。

输出校验与调度回读同时保留。例如,SQLite 的三条主命令均回读到 QoS 5、nice −20、uclamp 1024/1024,并通过输出校验。运行结果因此同时包含“算出了什么”“用了多久”和“当时向系统提交、读回了怎样的调度状态”,使单核测量能够回到具体执行条件中解释。

Comments

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

Comments

    Loading…