chakokuのブログ(rev4)

テック・コミック・DTM・・・ごくまれにチャリ

RPi Pico(RP2350)でC言語でLチカ、Pythonとの性能比較

背景:C言語とMicroPythonでどれぐらいの性能差があるのかを把握したい
取り組み:C言語でwaitなしのLチカ(GPIOのL/H)をやってみる。そのためSDKを使って簡単なバイナリをビルドする。
結果:C言語だと、1パルスの周期は34.4ns(29MHz)、システムクロック150MHzなので、概算で5命令ぐらいでループが実装されている計算、ソースを確認すると、ループは4命令で収まっている。MicroPythonの場合、チューニングしても、周期は1.44us(694.4KHz)
詳細:

RaspberryPiの公式ドキュメントを参照、まだ調べていませんが、UF2形式でバイナリを作るとUSBケーブルで転送できるようだ。picotoolというのもあるらしい。

www.raspberrypi.com
github.com

VS Code等を使わず手動でビルドしたいのだが。。Claudeに素でビルドしたいと相談。まずは開発環境を入れろと。

sudo apt update && sudo apt upgrade -y

sudo apt install -y \
  cmake \
  gcc-arm-none-eabi \
  libnewlib-arm-none-eabi \
  libstdc++-arm-none-eabi-newlib \
  build-essential \
  git \
  python3

(過去にARMマイコン用にビルドしたのか、WSL上には上記ツール一式がすでに入っていたのでインストール作業は省略)更新だけしておく

sudo apt update ; sudo apt upgrade --yes 

次にSDKを入れろと指示

cd ~/<some_path>
git clone https://github.com/raspberrypi/pico-sdk.git
cd pico-sdk
git submodule update --init

SDKの場所を定義しておく

export PICO_SDK_PATH=~/<some_path>

次はサンプルをビルドしてみろと

git clone https://github.com/raspberrypi/pico-examples.git
cd pico-examples
mkdir build && cd build
cmake ..
make blink -j4

ビルド待っている間にサンプルコードを入手
RPi Pico 2Wのボードに実装されているLEDのブリンクサンプル(ボード上のLEDはGPIOに直結されていないので、外付けCHIP経由で制御必要)

#include "pico/stdlib.h"
#include "pico/cyw43_arch.h"

int main() {
    // init Wi-Fi chip (need for LED Blink)
    if (cyw43_arch_init()) {
        return -1;  // initialize failed
    }

    while (true) {
        cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 1);  // LED ON
        sleep_ms(500);
        cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 0);  // LED OFF
        sleep_ms(500);
    }
}

picotoolをビルドするところで止まっているようなのだが。。

Downloading Picotool
Using picotool from /home/xxx/lang/c/rp2350/samples/build/_deps/picotool/picotool
TinyUSB available at /mnt/c/cygwin64/home/xxx/lang/c/rp2350/pico-sdk/lib/tinyusb/hw/bsp/rp2040; enabling build support for USB.
BTstack available at /mnt/c/cygwin64/home/xxx/lang/c/rp2350/pico-sdk/lib/btstack
cyw43-driver available at /mnt/c/cygwin64/home/xxx/lang/c/rp2350/pico-sdk/lib/cyw43-driver
Pico W Bluetooth build support available.
mbedtls available at /mnt/c/cygwin64/home/xxx/lang/c/rp2350/pico-sdk/lib/mbedtls
lwIP available at /mnt/c/cygwin64/homexxx/lang/c/rp2350/pico-sdk/lib/lwip
Pico W Wi-Fi build support available.
Skipping cache_perfctr example which is unsupported on this platform
Skipping ssi_dma example which is unsupported on this platform
Skipping multicore_fifo_irqs example which is unsupported on this platform
Skipping some Pico W examples as WIFI_SSID is not defined
Adding 20 BTstack examples of type 'background'
Skipping RTC examples as hardware_rtc is unavailable on this platform
Skipping universal examples as PICO_RISCV_TOOLCHAIN_PATH and PICO_ARM_TOOLCHAIN_PATH are not defined
Skipping TinyUSB dual examples, as TinyUSB hw/mcu/raspberry_pi/Pico-PIO-USB submodule unavailable
Skipping FreeRTOS examples as FREERTOS_KERNEL_PATH not defined
-- Configuring done (18.2s)

picotool使う予定ないのだが、毎回Picotoolのビルド?で詰まるので、picotoolだけを別途ビルドしてみる。以下の手順は”Getting started with Raspberry Pi”より

$ git clone https://github.com/raspberrypi/picotool.git 
$ cd picotool 
#You will also need to install libusb if it is not already installed, 
$ sudo apt install libusb-1.0-0-dev
$ mkdir build $ cd build 
$ export PICO_SDK_PATH=~/<spme_path>/pico-sdk 
$ cmake ../ 
$ make

エラーなくビルドはできたので、/usr/local/bin/に置いておく

sudo make install

これでpicotoolで詰まるのは解消と期待して、buildを再開

cmake .. -DPICO_BOARD=pico2_w -DPICOTOOL_FETCH_FROM_GIT=OFF

使っているボードがpico2_wなので、その件も指定しておく
先ほどのメッセージも処理が止まっているのではなく、単に時間がかかっているだけの可能性が高い。しばらく放置してみる
何も表示されない状態が4分続いた後、configが再開された。時間がかかるのはSDK自体をビルドしようとしているからかも。。
かなり時間がかかったが、つづいて、make blinkでビルド。blink.uf2が生成された。このコードはLEDがGPIOに直結されているのを前提に実装されているとのことで、Pico2Wの基板上のLEDは光らないだろう。

$ ls -ltr
total 3536
-rwxrwxrwx 1 someone someone 160165 May  5 10:38 Makefile*
-rwxrwxrwx 1 someone someone   1133 May  5 10:38 cmake_install.cmake*
drwxrwxrwx 1 someone someone   4096 May  5 11:16 CMakeFiles/
-rwxrwxrwx 1 someone someone 955660 May  5 11:37 blink.elf*
-rwxrwxrwx 1 someone someone 475376 May  5 11:37 blink.elf.map*
-rwxrwxrwx 1 someone someone 500064 May  5 11:37 blink.dis*
-rwxrwxrwx 1 someone someone 728842 May  5 11:37 blink.hex*
-rwxrwxrwx 1 someone someone 259100 May  5 11:37 blink.bin*
-rwxrwxrwx 1 someone someone 519168 May  5 11:37 blink.uf2*

MicroPythonが起動できず文鎮になってしまっているPico2Wに対して、USBケーブル経由で焼いてみる。
ClaudeはLED点滅しないと言っていたが、実際はPico2Wのボードでも点滅した。blink.cのソース追いかけてないのでなぜ光るのか不明だが。。
ビルド環境はできたとして、、まずはwaitなしでGPIO1をひたすらLHするプログラムを作る
file: blink.c

#include "pico/stdlib.h"
#include "hardware/clocks.h"  
#define OUT_PIN  1   // GPIO:1  for output

int main() {

    // set systemclock to 150MHz
    set_sys_clock_khz(150000, true);

    // setup GPIO for output
    gpio_init(OUT_PIN);
    gpio_set_dir(OUT_PIN, GPIO_OUT);

    while (true) {
       gpio_put(OUT_PIN, 1);   // H
       gpio_put(OUT_PIN, 0);   // L
    }

}

file: CMakeLists.txt

cmake_minimum_required(VERSION 3.13)
include($ENV{PICO_SDK_PATH}/pico_sdk_init.cmake)

project(blink C CXX ASM)          
set(CMAKE_C_STANDARD 11)
pico_sdk_init()

add_executable(myblink blink.c)    
target_link_libraries(myblink pico_stdlib  hardware_clocks)
pico_add_extra_outputs(myblink)

ビルド

mkdir build && cd build
cmake .. -DPICO_BOARD=pico2_w -DPICOTOOL_FETCH_FROM_GIT=OFF
make myblink 

GPIO:1に対してLHが繰り返されるのでオシロで計測、一周期は34.4nsで周波数は29MHz

システムクロックが150MHzなので、RP2350はARMでRISCと考えて、1命令1クロックだとすると、150/29 = 5.1より、ループはマシン語では約5命令ぐらいで動いている。Cで実装した場合の最速なのではかなろうか。
Claudeよりソース見れますよと言われたので再確認、以下の通り

$  arm-none-eabi-objdump -d myblink.elf | grep -A 20 main
10000274 <main>:
      *略*
10000294:       f000 fcd8       bl      10000c48 <set_sys_clock_pll>
10000298:       2001            movs    r0, #1
1000029a:       f000 f811       bl      100002c0 <gpio_init>
1000029e:       2301            movs    r3, #1
100002a0:       ec43 3044       mcrr    0, 4, r3, r3, cr4
100002a4:       f04f 0100       mov.w   r1, #0
100002a8:       f04f 0201       mov.w   r2, #1
100002ac:       ec42 3040       mcrr    0, 4, r3, r2, cr0
100002b0:       ec41 3040       mcrr    0, 4, r3, r1, cr0
100002b4:       e7f8            b.n     100002a8 <main+0x34>

LHのループは以下のコード、試算上は約5命令だったが実際の命令数としては4命令である

100002a8:       f04f 0201       mov.w   r2, #1
100002ac:       ec42 3040       mcrr    0, 4, r3, r2, cr0
100002b0:       ec41 3040       mcrr    0, 4, r3, r1, cr0
100002b4:       e7f8            b.n     100002a8 <main+0x34>

Claudeによるクロック計算は以下

MicroPythonによるLチカ

C言語でGPIOのL/Hを確認できたので、次はMicroPythonでどれぐらいの速度でL/Hできるかを確認する
GPIOのL/HをPythonで実装、REPLで貼り付けて実行すると、周期は21.6us , 46.3KHz

import machine
from machine import Pin

print(machine.freq())
pin1 = Pin(1, Pin.OUT)

while True:
    pin1.high()
    pin1.low()

関数にして最適化(@micropython.native デコレータ)を付けると、周期は4.1us , 243.9KHzまで改善

import machine
from machine import Pin

@micropython.native
def speed_test(pin):
    while True:
        pin.high()
        pin.low()
    
print(machine.freq())
pin1 = Pin(1, Pin.OUT)

speed_test(pin1)


補足:native最適化なしの単なる関数化だけの場合、周期は5us、周波数は200KHz。REPL実行から関数化すると一気に高速化する。関数実行の場合、中間コードに変換されているから早いのかと思ったけど、REPLでも中間コードに変換して実行するらしい。変数参照方式の差によると説明を受けた。関数内で実行しない場合、変数はグローバルスコープに所属しており、グローバルスコープの参照には時間がかかると(ハッシュ計算->辞書テーブル検索->オブジェクト取得の3ステップらしい(AIによる説明))

viperの最適化を付けると、周期は1.44us , 694.4KHzまで改善。コードはSIOレジスタのGPIO XOR用レジスタに対して0x0002を書き続けて、XOR動作によりL/Hを反転させる

import machine
from machine import Pin

#SIO_BASE_REG = 0xd0000000
#SIO_GPIO_OUT_XOR_REG_OFFSET = 0x28 
#SIO_GPIO_OUT_XOR_REG = SIO_BASE_REG + SIO_GPIO_OUT_XOR_REG_OFFSET

@micropython.viper
def _gp1_xor():
    while True:
        # 0xd0000028 : SIO_GPIO_OUT_XOR_REG
        ptr32(0xd0000028)[0] = 0x00_02  # 0x00_02 means gpio:1

print(machine.freq())
pin1 = Pin(1, Pin.OUT)
_gp1_xor()


RP2350 Datasheetより
SIOレジスタについて

■考察
コンパイル型言語のC言語とインタプリタ型言語のPython を単純に比べるのはどうかとは思うが、どれだけの性能差があるのか把握は必要

条件 C言語 REPL 関数化 関数化+native最適化 関数+viper最適化
周期 34.4ns 21.6us 5us 4.1us 1.44us
処理時間比 1 628倍 145倍 119倍 42倍

※最後の関数+viperはXORレジスタに0x2を書き続けるという反則のレベルのチューニングなので*1・・普通に頑張って120倍程度まで下げられるということかと。今回は単純はGPIOのL/Hだけですが、条件分岐等が入ってくるとさらに差が広がると思われます。

■補足
REPLだと遅い理由は変数アクセスの性能差と言う説明を受けた件

■追記
最後のXORレジスタを叩く実装はちょっと姑息な高速化かと思って、まずはSET/CLRレジスタにそれぞれ値を書くことでH/Lするように実装した。下記プログラムの場合、1周期は1.44usで周波数は694.4KHz

import machine
from machine import Pin

#SIO_BASE_REG = 0xd0000000
#SIO_GPIO_OUT_SET_REG_OFFSET = 0x18
#SIO_GPIO_OUT_CLR_REG_OFFSET = 0x20

@micropython.viper
def _gp1_set_clr():
    while True:
        # 0xd0000018 : SIO_GPIO_OUT_SET_REG
        ptr32(0xd0000018)[0] = 0x00_02  # 0x00_02 means gpio:1
        # 0xd0000020 : SIO_GPIO_OUT_CLR_REG
        ptr32(0xd0000020)[0] = 0x00_02  # 0x00_02 means gpio:1

print(machine.freq())
pin1 = Pin(1, Pin.OUT)
_gp1_set_clr()

結局XORを使った姑息な高速化と変わらない結果となった。この理由を考えると・・・XORの場合、一つのパルスを作るのに2回XORを実行、SET/CLRの場合は1つのパルスを作るのにSETが1回、CLRが1回で合計2回のコードを実行、よって実行されるレジスタ操作関数の数は変わらないと。むしろSET/CLRの方がループ回数が半分になるので実は有利なのかも。(XORの場合ループ2回回って、1パルス、SET/CLRの場合、ループ1回回って1パルス)

*1:XOR用レジスタに書くだけなので楽な実行で逃げていますが、今後時間があればGPIO_OUT_SET/GPIO_OUT_CLEARのレジスタに書く実装で試したい