Ubuntu Weekly Recipe

第724回CPUコアごとのベンチマークをPythonで簡単に取得してみる

第721回の新型ベアボーンキットであるDeskMeetと最新CPUであるAlder Lakeで夢のパワフルUbuntuライフでは、ASRockからリリースされたDeskMeet B660にUbuntuを入れて使ってみました。このB660では第12世代Core(Alder Lake)をサポートしており、Alder Lakeでは高性能コア(Performance Cores:P-Cores)と高効率コア(Efficient Cores:E-Cores)の二種類のコアを組み合わせたIntel Hybrid Technologyになっています。そこで今回はPythonを用いて、各コアのベンチマークを取ってみましょう。

LinuxのCPUベンチマーク事情

新しいCPUがリリースされると、各技術系のサイトからさまざまベンチマークの情報が提供されます。これらのベンチマークのほとんどはWindows上で取得したものです。定番のCinebenchや3DMarkであれば、Wineを利用してUbuntuでも利用可能な可能性が高いですし、CPUベンチマーク機能を持っているBlenderなんかは公式にUbuntuをサポートしているものの、⁠Windowsで計測した」ためにUbuntuとは別の結果になる可能性もあります。

継続的にLinuxのベンチマークを取得しているサイトだとPhoronixが有名です。Phoronixでは、Linuxも含むマルチプラットフォームなベンチマークランチャーであるPhoronix Test Suite(PTS)を開発し、それを用いてさまざまなガジェットのベンチマークを取得・公開しています[1]。Ubuntuでベンチマークと言えばまずPTSの利用が最初の選択肢となるでしょう。PTSで取得したデータは、OpenBenchmarking.orgにアップロードし、他のアップロード済みデータとも比較が可能になります[2]

Linux向けのCPUベンチマークに限って言えば、たとえば次のようなツールが定番です。

  • UnixBench:Dhrystoneをはじめとする定番のベンチマークセット。Ubuntuのリポジトリにはパッケージは用意されていない。サードパーティのsnapパッケージはある。
  • sysbench:データベース向けのベンチマークツールとして有名。CPUのベンチマークも可能。Ubuntuのリポジトリにはsysbenchパッケージが用意されている。
  • Blender:幅広く利用されている3DCG作成ツールである「Blender」を利用したCPU/GPUのベンチマークツール。
  • 7-Zip:定番のファイルアーカイバーで、簡単な圧縮・展開のベンチマークツールが備わっている。Ubuntuのリポジトリにはp7zip-fullパッケージが用意されている。
  • HPC Challenge:HPC(High-Perormance Computer)システムの性能評価に使われるツール。Ubuntuのリポジトリにもhpccパッケージが用意されている。
  • CoreMark:元々は組み込み向けのCPUコアをターゲットにしたベンチマークセット。基本機能を備えた通常版と浮動小数点演算に特化したPRO版がある。

Blenderを除くといずれもCLI前提のツールです。Blender自体もGUI無し版が存在し、Unix/Linux向けベンチマークはCLI版が一般的となっています。また、Linuxの場合はCPU以外のベンチマークツールのほうが充実しているかもしれません。これはCPUそのものの速さよりも、システム全体やデータベース、ウェブブラウザーといった特定のアプリケーションの性能のほうが重要視されがちで、特にCPUよりはストレージやネットワークがボトルネックになりがちなことも関係しているのでしょう。

ちなみに前述したように、CinebenchのようなWindows版のベンチマークツールであってもWineを経由してLinuxで動く可能性は高いです。特にゲーム系のベンチマークは、Steamを経由してProton上で動かす方法を取れば比較的簡単に動かせる可能性もあります。よって単にGPUを含むシステム全体のベンチマークを実施したいだけなら、これらのGUI系のツールも選択肢に入ってくるでしょう。

さて、今回はP-Core・E-Coreごとの性能および複数のコアを動かしたときの性能や温度の変化を見たいという目的があります。また、データの取得と視覚化はできれば分離したいところです。これぐらいの用途・規模であれば、⁠既存のツールを目的に沿うようにセットアップする」よりも「目的に沿ったツールを作る」ほうが楽なケースが多くなります。そこで既存のベンチマークプログラムそのものは流用しつつ、複数の条件で動作確認する部分をPythonで作ってみましょう。

Pythonでベンチマークを実施し視覚化する

今回は7-Zipをベンチマークツールとして使い、Pythonで結果を取得・視覚化を試みます。7-Zipを使う理由は次のとおりです。

  • パッケージとして簡単にインストールできること
  • CPUコア単体のベンチマークが取りやすいこと
  • 計測結果をPythonスクリプトから参照しやすいこと

ただし、パッケージが用意されているsysbench、Blender、HPC Challengeでも、そこまで大きな違いはありません。単に出力結果が一番わかりやすかったのが7-Zipというけです。

また、次のような理由からあえてPythonスクリプトを組んでベンチマークの取得を行おうとしています。

  • 単体・複数のCPUコアを組み合わせて順番にベンチマークを実施したい
  • ベンチマークの実施期間中、CPUの周波数・各種温度センサーの値を取得したい
  • 結果を機械読み取り可能なフォーマットで保存したい

Linuxの場合、sysfs/procfsを参照すれば比較的簡単に定量的なデータを収集可能です。後述するように今回は、第721回で利用したモニタリングツールであるGlancesもPythonでそれらのステータスを取得しています。また、ベンチマークの取得の時刻とモニタリングの時刻を動機させたかったので、⁠同じツールで結果を取得し、機械読み取り可能なデータとして保存したい」ために自前でスクリプトを書くことにしました[3]

UbuntuにおけるCPUベンチマークの注意点

先にUbuntuにおいてCPUベンチマークを計測する際の注意点をまとめておきましょう。なお、ここの説明のほとんどがIntel CPU向けの話です。

CPUFreqによる周波数のスケーリング

現在のLinuxはその負荷に応じて適切にCPUの周波数をコントロールする仕組みCPUFreqフレームワーク)が備わっています。CPUFreqはCPUの種類や世代によって適切な周波数コントロールを行うためのドライバーをロードし、そのドライバーに基づいて設定を行います。たとえばIntelの第二世代Core(Sandy Bridge)以降ならintel_pstateドライバーが使われることになるでしょう。

CPUFreqは「scaling governor」として設定したポリシーに従って周波数を設定します。ただしintel_pstateの場合は、独自のscaling governerを持っているため、Intelとそれ以外のCPUではこのあたりから考え方・用語が変わってくることに注意が必要です。またintel_pstate自体も、ハードウェアによるP-Stateの制御機能(HWP)やその他プロセッサーの世代ごとに実装されている各種省電力機能、カーネルバージョンによって挙動が異なります。

scaling governorはCPUの周波数をダイレクトに設定するか、現在の作業量に応じて動的に設定する挙動を行います。それとは別に「消費電力の状態」に応じて適切な周波数を設定するための機能が「Intel performance and energy biad hit(EPB⁠⁠」もしくは「Energy Performance Preference(EPP⁠⁠」です。EPB/EPPをサポートしているCPUであれば、その設定によって周波数の挙動を変えられます。Ubuntu 22.04 LTSのシステム設定にある「電源モード」は基本的にこの設定を変更します[4]

図1  システム設定の「電源管理」にある「電源モード」
図1

sysfsだとおおよそ次の項目から挙動を変更できます。

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
powersave

$ cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference
performance

ただしいちいちsysfsの適切な場所を思い出して設定するのは面倒です。そこで便利なツールがcpupowerコマンドとなります。cpupowerコマンドは最初からインストールされていないので、次のように追加のパッケージをインストールしてください。

$ sudo apt install linux-tools-generic

この手のツールはカーネルバージョンごとのsysfsの変更をフォローする必要があるため、バージョンごとに異なるバイナリを生成しています。上記のインストール方法だと、リポジトリにある最新のカーネル向けのパッケージがインストールされます。特定のバージョンをインストールしたければlinux-tools-$(uname -r)を指定してください。

$ sudo cpupower frequency-info
analyzing CPU 0:
  driver: intel_pstate
  CPUs which run at the same hardware frequency: 0
  CPUs which need to have their frequency coordinated by software: 0
  maximum transition latency:  Cannot determine or is not supported.
  hardware limits: 800 MHz - 5.10 GHz
  available cpufreq governors: performance powersave
  current policy: frequency should be within 800 MHz and 5.10 GHz.
                  The governor "powersave" may decide which speed to use
                  within this range.
  current CPU frequency: Unable to call hardware
  current CPU frequency: 4.86 GHz (asserted by call to kernel)
  boost state support:
    Supported: yes
    Active: yes

上記はCPU0だけの表示ですが、cpupower -c all frequency-infoとしてやるとすべてのCPUの状態を表示できます。たとえばE-Coreなら次のような結果になります。

analyzing CPU 23:
  driver: intel_pstate
  CPUs which run at the same hardware frequency: 23
  CPUs which need to have their frequency coordinated by software: 23
  maximum transition latency:  Cannot determine or is not supported.
  hardware limits: 800 MHz - 3.80 GHz
  available cpufreq governors: performance powersave
  current policy: frequency should be within 800 MHz and 3.80 GHz.
                  The governor "powersave" may decide which speed to use
                  within this range.
  current CPU frequency: Unable to call hardware
  current CPU frequency: 3.77 GHz (asserted by call to kernel)
  boost state support:
    Supported: yes
    Active: yes

ちなみにcpupowerだと、EPB/EPP関連の設定は見られないようです。同じlinux-tools-genericパッケージに含まれるx86_energy_perf_policyコマンドを利用します。

$ sudo x86_energy_perf_policy -r
cpu0: EPB 6
cpu0: HWP_REQ: min 11 max 64 des 0 epp 0 window 0x0 (0*10^0us) use_pkg 0
cpu0: HWP_CAP: low 1 eff 12 guar 31 high 64
cpu1: EPB 6
cpu1: HWP_REQ: min 11 max 64 des 0 epp 0 window 0x0 (0*10^0us) use_pkg 0
cpu1: HWP_CAP: low 1 eff 12 guar 31 high 64
(中略)
cpu23: EPB 6
cpu23: HWP_REQ: min 8 max 38 des 0 epp 0 window 0x0 (0*10^0us) use_pkg 0
cpu23: HWP_CAP: low 1 eff 9 guar 18 high 38
pkg0: HWP_REQ_PKG: min 1 max 255 des 0 epp 128 window 0x0 (0*10^0us)
pkg0: MSR_HWP_INTERRUPT: 0x00000000 (Excursion_Min-Disabled, Guaranteed_Perf_Change-Disabled)
pkg0: MSR_HWP_STATUS: 0x00000004 (Excursion_Min, No-Guaranteed_Perf_Change)

少しわかりにくいですが、manページにあるように次のような値の対応を持っているようです。

VALUE STRING            EPB  EPP
performance               0    0
balance-performance       4  128
normal,default            6  128
balance-power             8  192
power                    15  255

ベンチマークの際は、どちらのケースも常に全力を出せる「performance」に設定しておくと良いでしょう。

$ sudo cpupower -c all frequency-set -g performance
$ sudo x86_energy_perf_policy -a performance

thermaldによる周波数のスケーリング

CPUとは電力を熱に変える暖房器具です。高速に計算しようとすればするほど、消費電力も向上し、熱を発します。しかしながらCPUを構成する半導体は熱に弱い部品です。あんまり全力で計算しすぎると、自分が発した熱で壊れてしまいます。よって使う側はそのことを考えてやさしく扱ってあげなければいけないのです[5]

そこで登場するのがIntel製のthermaldです。何も指定しなければCPUコアの温度を監視し、100度以下に収まるようCPUの周波数を調整する(温度が下がるまで性能を落とす)デーモンです。ちなみにthermald自体は/etc/thermald/以下に適切なXMLファイルを書けば、100度以外のリミットを設けたり、ファンの速度を調整したり等、柔軟な調整が可能です。

ただし高温状態だとCPUが壊れてしまう可能性を考えると、性能のためにthermaldを切るということはないでしょう。よってベンチマークの際は各種温度も同時に監視して、thermald等によって何がしかの調整が入っていないかを確認するのが定番です。

Turbo Boost/Turbo Core Technorogy

IntelやAMDのCPUには、特定のCPUコアを一時的に定格よりも大きなクロックで動作することを許容する仕組みが存在します。特定のCPUコアが全開のその先にたどり着いたとしても(大きく発熱したとしても⁠⁠、他のコアに余力があれば、CPUパッケージ全体として耐熱性に余力があるから大丈夫だろう、という考え方です。

Ubuntuの場合、IntelのTurbo Boost Technologyは特に設定しなくてもCPU側の都合に合わせて自動的に活用します。また、もし「まったく動かしたくない」ということであれば、次のsysfsのファイルに1を書けば止められます。

$ cat /sys/devices/system/cpu/intel_pstate/no_turbo
0

ちなみにIntel以外だと/sys/devices/system/cpu/cpufreq/boostでコントロール可能なようです。

CPU affinity

Linuxにおいて、あるプロセスがどのCPUコアで動くかは決まっていません。最初のうちはCPU3で動いていたとしても、何かの拍子にCPU5で動くようになる、ということはよくあります。たとえば特定のPIDに対して/proc/<PID>/schedを表示すると、se.nr_migrationsという項目が、そのタスクがCPUコア間を移動した回数となります。

$ cat /proc/$$/sched
bash (2534043, #threads: 1)
-------------------------------------------------------------------
se.exec_start                                :     948566367.554390
se.vruntime                                  :      48240125.337559
se.sum_exec_runtime                          :           340.907496
se.nr_migrations                             :                   80
nr_switches                                  :                  708
nr_voluntary_switches                        :                  686
nr_involuntary_switches                      :                   22
se.load.weight                               :              1048576
se.avg.load_sum                              :                  193
se.avg.runnable_sum                          :               197632
se.avg.util_sum                              :               197632
se.avg.load_avg                              :                    4
se.avg.runnable_avg                          :                    4
se.avg.util_avg                              :                    4
se.avg.last_update_time                      :      948539438000128
se.avg.util_est.ewma                         :                    9
se.avg.util_est.enqueued                     :                    0
uclamp.min                                   :                    0
uclamp.max                                   :                 1024
effective uclamp.min                         :                    0
effective uclamp.max                         :                 1024
policy                                       :                    0
prio                                         :                  120
clock-delta                                  :                   67
mm->numa_scan_seq                            :                    0
numa_pages_migrated                          :                    0
numa_preferred_nid                           :                   -1
total_numa_faults                            :                    0
current_node=0, numa_group_id=0
numa_faults node=0 task_private=0 task_shared=0 group_private=0 group_shared=0

特定のCPUコアのベンチマークを取りたいなら、そのプロセスは特定のCPUコアでのみ動くことが求められます。これをコントロールするのが「CPU affinity」です。具体的には特定のPIDに対して、tasksetコマンドを利用するとCPU affinytの状態を確認できます。

$ taskset -p -c $$
プロセス ID 1956 の現在の親和性リスト: 0-23

上記の場合CPU0からCPU23までのいずれかのCPUコアで動くということがわかります。CPU affinityは「0,3,5」みたいな飛び飛びの値も、⁠7-10」みたいな連続した値も指定できますし、-cを省けば十六進数でビット指定も可能です。具体的に設定する方法は次のとおりです。

$ taskset -p -c 0,3,5 PID

affinityの後ろにPIDがくることに注意してください。

さまざまなCPUコアの組み合わせでベンチマークを取る

さて、ようやくPythonの話に戻ってきます。今回は手間を省くために次のような形で実装しました。

  • CPUの使用率や温度はGlancesを利用して取得する
  • PythonスクリプトとGlances、Glancesからデータを収集する部分はCPU1で動かす
  • /proc/cpuinfo/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freqの両方からCPU周波数の値を取得する
  • 7-Zipのベンチマークは1スレッドで実施7z b -mmt1を複数のCPUコアで実施)
  • 7-Zipのベンチマーク実行の間は30秒のクールタイムを設ける
  • 出力結果はJSONで保存する
  • Ubuntu 22.04 LTS、Python 3.10を前提とする

3番目のCPU周波数については、どうやらこの2個のファイルはそれぞれ計算方法が異なるようです。前者が慣らした値で、後者がほぼリアルタイムな値という説明が多いようですが、カーネルのバージョンによっても状況が異なるため、両方取得することにしました。

また、ベンチマークにおけるCPUコアの利用方法は次のとおりです。

  • CPUコアを0から順番に1コアずつ実施
  • Hyper Threading/SMTで作られたCPUスレッドについて、同じコアに属するスレッド同士をまとめて順番に実施(E-Coreの場合はシングルコアなので、上記と結果は同じ)
  • 偶数コアだけ同時に実施
  • 奇数コアだけ同時に実施
  • 全コア同時に実施

ベンチマークの実行環境にはあらかじめ、次のツールをインストールしておいてください。ただし最後のpython3-requests自体は、大抵のUbuntuには最初からインストールされているはずです。

$ sudo apt install glances p7zip-full python3-requests

というわけで、実際のPythonスクリプトは次のとおりです。このPythonスクリプトであるbenchmark.pyをこちらにアップロードしてありますので、必要に応じてダウンロードしてください。

#!/usr/bin/env python3

import datetime
import json
import multiprocessing
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import typing

import requests


def monitoring(
    queue: "multiprocessing.Queue[list[typing.Any]]",
    event: threading.Event,
    cpunum: int,
    epoch: datetime.datetime,
):
    data = []
    while not event.is_set():
        datum: dict[str, typing.Any] = {"time": 0, "cpu": {}, "sensor": {}}

        # Retrieve from glances
        datum["time"] = (datetime.datetime.now() - epoch).seconds
        percpu = requests.get("http://localhost:61208/api/3/percpu")
        sensors = requests.get("http://localhost:61208/api/3/sensors")

        # Retrieve from sysfs
        freq = {}
        if os.access("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", os.R_OK):
            for i in range(cpunum):
                with open(
                    "/sys/devices/system/cpu/cpu{0}/cpufreq/scaling_cur_freq".format(i)
                ) as f:
                    freq[i] = int(f.readline())

        # Retrieve from procfs
        hz = {}
        with open("/proc/cpuinfo") as f:
            cpuinfo = f.read().split("\n\n")
            for cpu, info in enumerate(cpuinfo):
                for line in info.split("\n"):
                    if "cpu MHz" in line:
                        hz[cpu] = float(line.split(":")[1].strip())

        # Organize retrieved data
        for stat in percpu.json():
            cpu = int(stat["cpu_number"])
            datum["cpu"][cpu] = {
                "usage": int(stat["total"]),
                "freq": freq[cpu],
                "hz": hz[cpu],
            }
        for stat in sensors.json():
            datum["sensor"][stat["label"]] = stat["value"]
        data.append(datum)
        time.sleep(1)

    queue.put(data)


def parse_smp_cores():
    pattern = re.compile(
        r"processor\s+:\s+(?P<logi>\d+)|physical id\s+:\s+(?P<phys>\d+)|core id\s+:\s+(?P<core>\d+)"
    )
    cores = {}
    with open("/proc/cpuinfo") as f:
        cpuinfo = f.read().split("\n\n")
        for block in cpuinfo:
            if len(block) == 0:
                continue
            coreinfo = {}
            for m in re.finditer(pattern, block):
                coreinfo.update({k: int(v) for k, v in m.groupdict().items() if v})
            # On the assumption that under max core per a CPU package will be less than 2^16.
            key = str(coreinfo["phys"] << 16 | coreinfo["core"])
            if key not in cores:
                cores[key] = []
            cores[key].append(coreinfo["logi"])

    return cores


def bench_7z(
    queue: "multiprocessing.Queue[dict[str, typing.Any]]",
    cpu: int,
    epoch: datetime.datetime,
):
    comm = shutil.which("7z") or sys.exit("needs 7z command")
    os.sched_setaffinity(0, {cpu})
    result = subprocess.run([comm, "b", "-mmt1"], stdout=subprocess.PIPE)
    if result.returncode != 0:
        sys.exit("failed to {0} on cpu {1}".format(comm, cpu))
    end = (datetime.datetime.now() - epoch).seconds
    data = []
    for line in result.stdout.decode("utf-8").splitlines():
        if line.startswith("Tot:"):
            data = line.split()
            break
    queue.put(
        {
            "end": end,
            "result": (int(data[2]) + int(data[3])) / 2,
        }
    )


if __name__ == "__main__":
    # Start glances as daemon
    glances = shutil.which("glances") or sys.exit("needs glances command")
    daemon = subprocess.Popen(
        [glances, "-w", "--disable-webui"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        bufsize=0,
        pipesize=0,
    )
    os.sched_setaffinity(daemon.pid, {1})

    # Wait to start server
    time.sleep(3)

    # Get CPU info
    result = requests.get("http://localhost:61208/api/3/quicklook")
    cpuname = result.json()["cpu_name"]
    cpunum = len(os.sched_getaffinity(0))
    data = {"cpunum": cpunum, "name": cpuname, "system": " ".join(os.uname())}

    # Prepare time epoch
    os.sched_setaffinity(0, {1})
    epoch = datetime.datetime.now()

    # Start monitor thread
    queue = multiprocessing.Queue()
    event = multiprocessing.Event()
    monitor = multiprocessing.Process(
        target=monitoring,
        args=(
            queue,
            event,
            cpunum,
            epoch,
        ),
        daemon=True,
    )
    monitor.start()
    if not monitor.pid:
        sys.exit("failed to start monitor process")
    os.sched_setaffinity(monitor.pid, {1})

    # Start benchmark processes
    do_bench = bench_7z
    data["benchmark"] = []
    start = (datetime.datetime.now() - epoch).seconds
    reset = {
        "time": (datetime.datetime.now() - epoch).seconds,
        "cpu": dict.fromkeys(range(cpunum), {"end": 0, "result": 0}),
    }
    data["benchmark"].append(reset)
    time.sleep(3)
    patterns = [(x,) for x in range(cpunum)]  # single core
    patterns = [(x,) for x in range(cpunum)]  # single core
    patterns.extend([tuple(v) for _, v in parse_smp_cores().items()])  # with SMT
    patterns.append(tuple([x for x in range(0, cpunum, 2)]))  # without SMT, even cores
    patterns.append(tuple([x for x in range(1, cpunum, 2)]))  # without SMT, odd cores
    patterns.append(tuple([x for x in range(cpunum)]))  # all cores
    for pattern in patterns:
        print("Start benchmark on CPU", end=" ", file=sys.stderr)
        start = (datetime.datetime.now() - epoch).seconds
        benchmark_result: list[dict[str, typing.Any]] = [{"time": start, "cpu": {}}]
        bench = {}
        for i in pattern:
            print("{}".format(i), end=" ", file=sys.stderr)
            bench[i] = {}
            bench[i]["queue"] = multiprocessing.Queue()
            bench[i]["proc"] = multiprocessing.Process(
                target=do_bench,
                args=(
                    bench[i]["queue"],
                    i,
                    epoch,
                ),
            )
            bench[i]["proc"].start()
            if not bench[i]["proc"].pid:
                sys.exit("failed to start benchmark process")
        print(file=sys.stderr)
        for i in pattern:
            bench[i]["proc"].join()
            if bench[i]["proc"].exitcode != 0:
                sys.exit("failed benchmark process")
            result = bench[i]["queue"].get()
            benchmark_result[0]["cpu"][i] = result
            end = {"time": result["end"], "cpu": {}}
            end["cpu"][i] = result
            benchmark_result.append(end)
        data["benchmark"].extend(benchmark_result)
        reset = {
            "time": (datetime.datetime.now() - epoch).seconds,
            "cpu": dict.fromkeys(range(cpunum), {"end": 0, "result": 0}),
        }
        data["benchmark"].append(reset.copy())
        time.sleep(30)
        reset["time"] = (datetime.datetime.now() - epoch).seconds
        data["benchmark"].append(reset)

    # Output retrieved data
    event.set()
    data["monitoring"] = queue.get()
    print(json.dumps(data))

    # Finalize monitor thread and glances daemon
    event.set()
    monitor.join()
    daemon.terminate()

記事を書きながらトライアンドエラーしていたため、いろいろハードコードな部分が残っていますがそのあたりはご愛嬌ということで。各自の好みに応じてもっとうまいやり方に直して実施していただければと思います。

特にこの方法だと、センサーの取得結果が「摂氏単位の温度センサー」しか存在しない前提となっています。本来はモジュールを適切にロードしておけば、ファンの回転速度等も取得できるため、もう少し調整が必要です。

あとは次のように出力ファイルを指定して実行するだけです。

$ ./benchmark.py > result.json
Start benchmark on CPU 0
Start benchmark on CPU 1
Start benchmark on CPU 2
Start benchmark on CPU 3
Start benchmark on CPU 4
Start benchmark on CPU 5
Start benchmark on CPU 6
Start benchmark on CPU 7
Start benchmark on CPU 8
Start benchmark on CPU 9
Start benchmark on CPU 10
Start benchmark on CPU 11
Start benchmark on CPU 12
Start benchmark on CPU 13
Start benchmark on CPU 14
Start benchmark on CPU 15
Start benchmark on CPU 16
Start benchmark on CPU 17
Start benchmark on CPU 18
Start benchmark on CPU 19
Start benchmark on CPU 20
Start benchmark on CPU 21
Start benchmark on CPU 22
Start benchmark on CPU 23
Start benchmark on CPU 0 1
Start benchmark on CPU 2 3
Start benchmark on CPU 4 5
Start benchmark on CPU 6 7
Start benchmark on CPU 8 9
Start benchmark on CPU 10 11
Start benchmark on CPU 12 13
Start benchmark on CPU 14 15
Start benchmark on CPU 16
Start benchmark on CPU 17
Start benchmark on CPU 18
Start benchmark on CPU 19
Start benchmark on CPU 20
Start benchmark on CPU 21
Start benchmark on CPU 22
Start benchmark on CPU 23
Start benchmark on CPU 0 2 4 6 8 10 12 14 16 18 20 22
Start benchmark on CPU 1 3 5 7 9 11 13 15 17 19 21 23
Start benchmark on CPU 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23

計測結果を視覚化する

次に生成したJSONファイルを視覚化しましょう。こちらについてはPandasでJSONファイルを読み込み、Matplotlibでグラフの描画を行っています。よって次のツールをインストールする必要があります。

$ sudo apt install python3-matplotlib python3-pandas

実際のPythonスクリプトは次のとおりです。このPythonスクリプトであるplot.pyをこちらにアップロードしてありますので、必要に応じてダウンロードしてください。

#!/usr/bin/env python3

import argparse
import json
import os
import re
import sys

import matplotlib.pyplot as plt
import pandas


def rename_legend(s: str):
    s = re.sub("^cpu.([0-9]+)\\.", "CPU\\1 ", s)
    return re.sub("^sensor.(.*)$", "\\1", s)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("json")
    parser.add_argument("output")
    args = parser.parse_args()

    data = []
    if not os.access(args.json, os.R_OK):
        sys.exit("could not read json {}".format(args.json))

    with open(args.json) as f:
        data = json.load(f)

    cpunum = data["cpunum"]
    system = "\n".join((data["name"],data["system"]))

    plots = [
        {"title": "CPU Usage(%)", "filter": "(time|.usage$)", "style": "plain"},
        {
            "title": "Even CPU Freq via sysfs (Hz)",
            "filter": "(time|\\d*[02468].freq$)",
            "style": "plain",
        },
        {
            "title": "Odd CPU Freq via sysfs (Hz)",
            "filter": "(time|\\d*[13579].freq$)",
            "style": "plain",
        },
        {
            "title": "Even CPU Freq via procfs (MHz)",
            "filter": "(time|\\d*[02468].hz$)",
            "style": "plain",
        },
        {
            "title": "Odd CPU Freq via procfs (MHz)",
            "filter": "(time|\\d*[13579].hz$)",
            "style": "plain",
        },
        {"title": "Sensors (C)", "filter": "(time|^sensor.)", "style": "plain"},
    ]
    fig, axes = plt.subplots(
        ncols=1, nrows=len(plots) + 1, sharex=False, figsize=(20, 40)
    )
    fig.suptitle(system, fontsize=20, y=0.9)
    df_mon = pandas.json_normalize(data["monitoring"])
    for i, v in enumerate(plots):
        df_mon.filter(regex=v["filter"]).rename(columns=rename_legend).plot(
            x="time", ax=axes[i]
        ).legend(loc="upper left", bbox_to_anchor=(1, 1), fontsize="small")
        axes[i].set_title(v["title"])
        axes[i].ticklabel_format(style=v["style"])
        axes[i].grid()

    df_bench = pandas.json_normalize(data["benchmark"])
    df_bench.filter(regex="(time|result)").fillna(method="ffill").rename(
        columns=rename_legend
    ).plot.area(x="time", ax=axes[len(plots)], stacked=False,).legend(
        loc="upper left", bbox_to_anchor=(1, 1), fontsize="small"
    )
    axes[len(plots)].set_title("Benchmark result")
    axes[len(plots)].grid()

    fig.savefig(args.output, bbox_inches="tight")

記事を書きながらトライアンドエラーしていたため、いろいろハードコードな部分が残っていますがそのあたりはご愛嬌ということで。各自の好みに応じてもっとうまいやり方に直して実施していただければと思います(2回目⁠⁠。

さすがに24コアもあるとひとつのグラフにまとめてしまうとごちゃごちゃになってわかりづらかったので、偶数コア・奇数コアでグラフを分割してみました。それでもわかりづらい部分は多いので、もっと見やすい形にする方法を考えたほうが良さそうですね。

実行のしかたはJSONファイルと出力ファイルを指定するだけです。

$ ./plot.py result.json result.pdf

出力フォーマットは、出力ファイルの拡張子に準じます。

実行結果を見てみる

さて具体的な実行結果を見ていきましょう。実際の出力結果は1ファイルにまとまっていますが、ここでは説明のしやすいように上から分割していきます。

図2  CPUの使用率
図2

CPUコアごとの使用率の遷移です。100%になっているところがベンチマークを実行中の区間だと考えるときれいに試行回数分ピークが登場しています。また、ピーク期間の幅が広い=ベンチマークに時間がかかっている=性能が落ちているという状態です。最初の16回(P-Core)に比べると、次の8回(E-Core)の幅は広くなっています。また、最後の全コア同時に実施した際もやはり性能が落ちているようです。

図3  syfs経由のCPUの周波数取得
図3

/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freqからCPUコアごとに取得した周波数情報です。ほぼリアルタイムの値と考えられます。今回利用したIntel Core i9-12900は次のような性能を備えています。

  • Turbo Boost:5.10 GHz
  • P-Core Turbo Boost:5.00 GHz
  • E-Core Turbo Boost:3.80 GHz
  • P-Core Base Frequency:2.40 GHz
  • E-Core Base Frequency:1.80 GHz

今回は周波数設定を「performance」にしているために、全体がほぼ上限にはりついています。下のオレンジ色のラインがE-Coreです。つまりP-Coreは適宜周波数を落としたほうが良いという判断がなされていますが、E-Coreはずっと全力で回していてもさほど問題はないようです。

1500秒を超えたあたりと最後のほうでE-Coreも含めて全体的に周波数が落ちている領域がありますが、これは後ほど確認する温度センサーの結果から、おそらくthermaldによるサーマルスロットリングが発生したのだと思われます。

図4  procfs経由のCPUの周波数取得
図4

/proc/cpuinfoのほうはもうすこし慣らした値が表示されるようです。よって、sysfsのそれよりはわかりやすいですね。こちらも偶数コアと奇数コアに分けています。

最初の16コアがP-Coreで、次の一段低い8コアがE-Coreです。CPU1はパフォーマンスモニタ等を動かしている都合上、ずっと上に張り付いています。

1300秒から2300秒ぐらいにかけては、同じ物理コアに属する論理コアを同時に動かしています。今回のCPUだとCPU0とCPU1、CPU2とCPU3がそれぞれ同じ物理コアに属する形なので、上下のグラフで同時にピークが来ています。ただしSMTで全力で回すとすぐに高熱になってしまうらしく、1500秒を超えたあたりから周波数が落ちる形になっています。ちなみにE-CoreはSMT非対応なので、1800秒ぐらいから先はシングルコアでのテストと結果は同じです。

また2300秒ぐらいから先の偶数コアだけ・奇数コアだけ・全コアのベンチマークは、全然性能が出ていませんね。

図5  温度とベンチマークスコア
図5

最後はセンサーで取得した温度の結果と、7-Zipのベンチマークスコアです。

P-Coreを全力で回している間はさすがに温度が上昇するもののシングルコア程度ならゆるやかです。またその後のE-Coreのベンチマークの際はほとんど温度があがっていません。むしろ少し前のP-Coreで上昇した分を戻しています。

1300秒ぐらいから2コアずつベンチマークを行う形にしている結果、CPU8とCPU9のベンチマークあたりで100度に到達してしまいました。下段のベンチマークスコアをみてもこのあたりで少し下がっています。というかそもそも2コア同時に実施すると、E-Core単体よりも性能が下がっているみたいですね。

ベンチマークスコアの最後の3本は、次のようなパターンのベンチマークです。

  • 偶数コア全部でベンチマークを走らせる
  • 奇数コア全部でベンチマークを走らせる
  • 全コアでベンチマークを走らせる

前者が2色に分かれているのは、上がP-Coreによるもの、下がE-Coreにるものです。つまりE-Coreもスコアが下がっていることになります。さらに全コア同時だとシングルコアの半分以下の結果です。当たり前の話ではありますが、今回のシステムにおいてCPUの性能を発揮するためには、まずは冷却を頑張らないといけなさそうな雰囲気です[6]

ただし性能低下は温度だけでは説明できません。今後Alder Lakeの真価を発揮するにはP-Core/E-Coreの使い分けが要になってきそうです。

おすすめ記事

記事・ニュース一覧