TinyGoでSDカードを使う (速度改善編)

2022年5月7日土曜日

Raspberry Pi Pico TinyGo

t f B! P L

Raspberry Pi PicoとTinyGoでSDカードを使う (FatFs)の記事では、TinyGoでのSDカードアクセスは特にRead性能において、C++と比べてかなりの低下が見られましたので、その原因と改善を試みました。

速度低下の要因

比較に用いたのは以下のプロジェクトです。

ロジアナ波形比較

まず、ロジアナでリードアクセス時 (512 Byte転送時) のSPI波形をモニターしてみました。

TinyGoでのSPI波形
C++でのSPI波形

どちらもSPIのSCK周波数は125 MHz / 4 = 31.25 MHzであるのに対して、8bitアクセスを開始してから次の8bitアクセスを開始するまでの時間に、C++では310ns、TinyGoでは1.75usと非常に大きな差があることが確認できました。

コードの比較

そこで、ロジアナで確認した波形部分に相当する双方のコードを確認してみました。どちらもプロジェクトではなくSDK側にあるコードです。

tinygo/src/machine/machine_rp2040_spi.go

func (spi SPI) rx(rx []byte, txrepeat byte) error {
	var deadline = ticks() + _SPITimeout
	plen := len(rx)
	const fifoDepth = 8 // see txrx
	var rxleft, txleft = plen, plen
	for txleft != 0 || rxleft != 0 {
		if txleft != 0 && spi.isWritable() && rxleft < txleft+fifoDepth {
			spi.Bus.SSPDR.Set(uint32(txrepeat))
			txleft--
		}
		if rxleft != 0 && spi.isReadable() {
			rx[plen-rxleft] = uint8(spi.Bus.SSPDR.Get())
			rxleft--
			continue // if reading succesfully in rx there is no need to check deadline.
		}
		if ticks() > deadline {
			return ErrSPITimeout
		}
	}
	return nil
}

pico-sdk/src/rp2_common/hardware_spi/spi.c

int __not_in_flash_func(spi_read_blocking)(spi_inst_t *spi, uint8_t repeated_tx_data, uint8_t *dst, size_t len) {
    invalid_params_if(SPI, 0 > (int)len);
    const size_t fifo_depth = 8;
    size_t rx_remaining = len, tx_remaining = len;

    while (rx_remaining || tx_remaining) {
        if (tx_remaining && spi_is_writable(spi) && rx_remaining < tx_remaining + fifo_depth) {
            spi_get_hw(spi)->dr = (uint32_t) repeated_tx_data;
            --tx_remaining;
        }
        if (rx_remaining && spi_is_readable(spi)) {
            *dst++ = (uint8_t) spi_get_hw(spi)->dr;
            --rx_remaining;
        }
    }

    return (int)len;
}

まとめるとこんな感じでしょうか。

  • TinyGoではマイコンのレジスタアクセスのようなmachineパッケージの一番最下層まで(Tiny)Goで記述されている。
  • TinyGoのrx()関数は、タイムアウトエラー処理の追加、戻り値の違いを除き、spi.cのspi_read_blocking()関数の内容がそっくりそのまま移植されている。

行っている処理の内容自体は、TinyGoとCで全く同じと言えるので、原因はTinyGoを介したレジスタアクセスによるオーバーヘッドにありそうです。

速度改善の修正

machineパッケージ

関数の目星がついたところで、machineパッケージのうち、machine.SPIに対して、パフォーマンスに影響がある部分に関しては、C関数をコールするように修正を考えます。 通常のパッケージであれば、ローカルに修正版を置いてそちらを参照するようにすれば既存パッケージの名前を変更することなく置き換えができるのですが、machineパッケージの場合、この方法はうまく行きませんでした。 その理由はこのあたりにも記載されていましたが、machineが厳密にはパッケージではなく、TinyGoのライブラリであるからのようです。 したがって、埋め込み(embedded)により、mymachine.SPIタイプを定義して、mymachine.SPIをレシーバとした関数を定義していくことにしました。
またmachine.SPIを呼び出しているTinyFsのsdcardパッケージについては、mymachine.SPIに置き換えが必要なのでローカルに持ってきて修正を加えました。


pico_tinygo_fatfs_test/mymachine/machine_rp2040_spi.go

// +build rp2040

package mymachine

// #include "./spi.h"
import "C"
import (
	"machine"
	"device/rp"
	"errors"
	"unsafe"
)

type SPI struct {
	*machine.SPI
}
...

pico_tinygo_fatfs_test/sdcard/sdcard.go

package sdcard

import (
	"fmt"
	"machine"
	"time"

	"pico_tinygo_fatfs_test/mymachine"
)
...
func New(b mymachine.SPI, sck, sdo, sdi, cs machine.Pin) Device { // machine.SPIは、mymachine.SPIに置き換える。(その他はmachineを維持)
	return Device{
		bus:        b,
		cs:         cs,
		sck:        sck,
		sdo:        sdo,
		sdi:        sdi,
		cmdbuf:     make([]byte, 6),
		dummybuf:   make([]byte, 512),
		tokenbuf:   make([]byte, 1),
		sdCardType: 0,
	}
}
...

main側では、修正したパッケージをローカルからのインポートに変更して、spi変数をmachine.SPIからmymachine.SPIに置き換えました。


pico_tinygo_fatfs_test/main.go

package main

import (
	"fmt"
	"machine"
	"time"
	"os"

	//"tinygo.org/x/drivers/sdcard"
	"pico_tinygo_fatfs_test/sdcard"
	//"tinygo.org/x/tinyfs/fatfs"
	"pico_tinygo_fatfs_test/fatfs"
	"pico_tinygo_fatfs_test/mymachine"
)

var (
	spi    mymachine.SPI
	sckPin machine.Pin
	sdoPin machine.Pin
	sdiPin machine.Pin
	csPin  machine.Pin
	ledPin machine.Pin

	serial  = machine.Serial
)
...

rx()関数からC呼び出し

rx()関数をC側のspi_read_blocking()関数を呼び出すために書き換えます。 spi_read_blocking()関数の第1引数spi_instに関しては、該当SPIデバイスのレジスタの先頭アドレスを示すように合わせこみが必要です。 その他の引数に関しては、そのままC側の型に合わせてキャストをするのみです。 なお、タイムアウト処理については削除しました。


pico_tinygo_fatfs_test/mymachine/machine_rp2040_spi.go

func (spi SPI) rx(rx []byte, txrepeat byte) error {
	spi_inst := (*C.spi_inst_t)(unsafe.Pointer(&spi.Bus.SSPCR0.Reg))
	repeated_tx_data := C.uint8_t(txrepeat)
	dst := (*C.uint8_t)(unsafe.Pointer(&rx[0]))
	plen := C.size_t(len(rx))
	C.spi_read_blocking(spi_inst, repeated_tx_data, dst, plen)
	return nil
}

Cソースに関しては、pico-sdk/src/rp2_common/hardware_spi/spi.cを、できるだけそのままの形で/pico_tinygo_fatfs_test/mymachine以下に配置しました。 また、spi.c, spi.hのコンパイルを通すため、pico-sdkからいくつかのファイルを配置しました。(mymachine/hardware以下)


pico_tinygo_fatfs_test/mymachine/spi.c

int spi_read_blocking(spi_inst_t *spi, uint8_t repeated_tx_data, uint8_t *dst, size_t len) {
    invalid_params_if(SPI, 0 > (int)len);
    const size_t fifo_depth = 8;
    size_t rx_remaining = len, tx_remaining = len;

    while (rx_remaining || tx_remaining) {
        if (tx_remaining && spi_is_writable(spi) && rx_remaining < tx_remaining + fifo_depth) {
            spi_get_hw(spi)->dr = (uint32_t) repeated_tx_data;
            --tx_remaining;
        }
        if (rx_remaining && spi_is_readable(spi)) {
            *dst++ = (uint8_t) spi_get_hw(spi)->dr;
            --rx_remaining;
        }
    }

    return (int)len;
}

速度改善の確認

rx()関数の中身をC呼び出しに変更した後に、改めてベンチマークとロジアナで波形を確認しました。

ベンチマーク

  • pico_fatfs_test (C++)

    =====================
    == pico_fatfs_test ==
    =====================
    mount ok
    Type is FAT32
    Card size:   32.00 GB (GB = 1E9 bytes)
    
    FILE_SIZE_MB = 5
    BUF_SIZE = 512 bytes
    Starting write test, please wait.
    
    write speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    447.7192, 6896, 1007, 1142
    446.4797, 7589, 1024, 1145
    
    Starting read test, please wait.
    
    read speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    974.9766, 1050, 403, 524
    974.4066, 1049, 402, 524
        

  • pico_tinygo_fatfs_test (TinyGo machine.SPIのC呼び出し変更後)

    ============================
    == pico_tinygo_fatfs_test ==
    ============================
    mount ok
    Type is FAT32
    Card size:   32.00 GB (GB = 1E9 bytes)
    
    FILE_SIZE_MB = 5
    BUF_SIZE = 512 bytes
    Starting write test, please wait.
    
    write speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    390.2342, 17208, 1092, 1292
    362.8215, 54979, 897, 1383
    
    Starting read test, please wait.
    
    read speed and latency
    speed,max,min,avg
    KB/Sec,usec,usec,usec
    835.5080, 16601, 554, 591
    830.9257, 21483, 559, 594
        


かなり速度改善されて、C++に近いパフォーマンスとなりました。

ロジアナ波形

TinyGo machine.SPIのC呼び出し変更後

8bitアクセスを開始してから次の8bitアクセスを開始するまでの時間が1.75us → 360nsと大幅に改善されていることが確認できました。C++版と全く同じC関数をTinyGo環境でも使用しているのですが、まだ微妙な差があります。C++版のコンパイラがarm-none-eabi-gccであるのに対して、TinyGoではLLVM/Clangであることと関連があるかもしれませんが、かなり近い速度が得られているので、一旦ここまでとします。


まとめ

TinyGoのmachineパッケージの一部をCgo化することによって、C/C++水準のアクセス速度を実現することが出来ました。Cgoの機能も、呼び出し前のコピーの必要性はあるにせよ、非常にシンプルで使いやすいと言えます。 一方で、もともとmachineパッケージがすべてTinyGoで記述されているのは、Go言語の利点を損なわないための意図があると思われますので、 パフォーマンス向上のためにCgo化するアプローチが、選択肢になるかどうかは、意見の分かれるところでもあります。 例えば、今回のようなデータ転送部分には、レジスタの直接アクセスを頻繁に用いず、DMAなどを活用してオーバーヘッドを減らしつつGo記述を保つような方法も検討すべきかもしれません。

自己紹介

自分の写真
電子工作&プログラミング、オーディオ・音楽

注目の投稿

Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。 ※2023年6月6日: ソースコード修正の内容を反映させました。 時刻合わせ風景 概要 電波時計は電波が届くところで使...

QooQ