MicroPython 性能の最大化

このチュートリアルでは、MicroPython コードの性能を向上させる方法について説明します。他の言語も使った最適化としては、C言語で書かれたモジュールと MicroPython インラインアセンブラを扱っています。

高性能なコードを開発するプロセスは、次の段階を順番に実施します。

  • 性能のための設計
  • コーディングとデバッグ

最適化の段階:

  • コードの最も遅い部分を特定。
  • Python コードの効率を向上。
  • ネイティブコードエミッターを使用。
  • バイパーコードエミッタを使用。
  • ハードウェア固有の最適化を使用。

性能のための設計

性能の問題は最初から考慮する必要があります。これには、性能が最も重要となるコードのセクションを見て、その設計に特に注意を払うことが含まれます。最適化のプロセスは、コードがテストされたときに始まります。設計が最初から正しい場合、最適化は簡単で実際には不要な場合があります。

アルゴリズム

性能のためにルーチンを設計する際の最も重要な側面は、最良のアルゴリズムが採用されることを確実にすることです。これは MicroPython でガイドするようなことではなく教科書のトピックですが、効率がよく知られているアルゴリズムを採用することで、パフォーマンスが大幅に向上することがあります。

RAM の割り当て

効率的な MicroPython コードを設計するには、インタプリタが RAM を割り当てる方法を理解しておく必要があります。オブジェクトが作成されるかサイズが大きくなると(たとえば、項目がリストに追加される場合など)、必要な RAM はヒープと呼ばれるブロックから割り当てられます。これにはかなりの時間がかかります。さらに場合によっては、ガベージコレクションとして知られるプロセスを起動します。

したがって、オブジェクトが1回しか作成されず、サイズが大きくなることを許可されていない場合、関数またはメソッドのパフォーマンスは向上します。これは、オブジェクトがその使用期間中存続することを意味します。通常、クラスコンストラクタ内でインスタンス化され、さまざまなメソッドで使用されます。

これについては、後述の ガベージコレクションの制御 でさらに詳しく説明します。

バッファ

先に、デバイスとの通信にバッファを必要とする一般的な例がありました。典型的なドライバーは、コンストラクタ内にバッファを作成し、それを繰り返し呼び出される I/O メソッドで使います。

MicroPython ライブラリは通常、事前に割り当てられたバッファをサポートします。たとえば、ストリームインタフェースをサポートするオブジェクト(ファイルや UART など)は、読み取りデータ用に新しいバッファを割り当てる read() メソッドを提供し、既存のバッファにデータを読み取る readinto() メソッドも提供します。

浮動小数点

MicroPython ポートによっては浮動小数点数をヒープに割り当てます。ポートによっては専用の浮動小数点コプロセッサを欠いていて、整数よりもかなり低い速度で算術演算を「ソフトウェア」実行するかもしれません。性能が重要な場合は、整数演算を使用し、浮動小数点の利用を性能が重要ではないコードのセクションに制限します。たとえば、ADC の読み値を整数値として1回の配列で取得し、その後で信号処理のためにそれらを浮動小数点数に変換します。

配列

リストに代わるものとして、さまざまなタイプの配列クラスの利用を検討してください。array モジュールは、Python の組み込みクラス bytesbytearray クラスでサポートされている8ビット要素を含むさまざまな要素型をサポートしています。これらのデータ構造はすべて、隣接するメモリ位置に要素を格納しています。クリティカルコードでのメモリ割り当てを避けるために、これらは事前に割り当てられ、引数またはバインドされたオブジェクトとして渡されるべきです。

bytearray インスタンスなどのオブジェクトのスライスを渡すとき、Python はスライスのサイズに比例したサイズの割り当てを含むコピーを作成します。これは memoryview オブジェクトを使用して軽減できます。 memoryview 自体はヒープ上に割り当てられますが、それが指すスライスのサイズに関係なく、小さい固定サイズのオブジェクトです。 memoryview をスライスすると、新しい memoryview が作成されるため、割り込みサービスルーチンでは実行できません。また、スライス構文の a:b では、 slice(a, b) オブジェクトをインスタンス化することで、さらに割り当てを行います。

ba = bytearray(10000)  # 巨大な配列
func(ba[30:2000])      # 新規に 2K のコピーを割り当てて、渡されます
mv = memoryview(ba)    # 小さなオブジェクトが割り当てられます
func(mv[30:2000])      # メモリのポインターが渡されます

memoryview はバッファプロトコルをサポートするオブジェクトにのみ適用できます。これには配列は含まれますがリストは含まれません。ちょっとした注意点は、 memoryview オブジェクトが生きている間は、元のバッファオブジェクトも生き続けているということです。だから、 memoryview は普遍的な万能薬ではありません。たとえば、上の例で、10Kバッファを使い終わって、そこからそれらのバイト 30:2000 だけが必要な場合は、代わりにスライスを作成して、10K バッファを移動させます(ガベージコレクションの対象となるようにします)。こうすることで、長命の memoryview を作成して、10K が GC からブロックされたままになるようなことがなくなります。

それでも、 memoryview は高度な事前割り当てバッファ管理に欠かせません。先にとりあげた readinto() メソッドでは、データをバッファの先頭から置いていき、バッファ全体を埋めます。既存のバッファの途中にデータを配置する必要がある場合はどうしますか? バッファの必要なセクションにメモリビューを作成してそれを readinto() に渡すだけです。

コードの最も遅い部分を特定する

これはプロファイリングとして知られている手続きで、教科書でもとりあげられていて、(標準的な Python のために)さまざまなソフトウェアツールでサポートされています。MicroPython プラットフォーム上で実行される可能性が高い小型の組込みアプリケーションについて、最も遅い関数またはメソッドは time モジュールにある ticks 関数群を賢明に使うことで特定できます。コードの実行時間は、ms, us, または CPU サイクルで測定できます。

以下を @timed_function デコレータとして任意の関数またはメソッドに追加することで、実行時間の計測が可能になります。

def timed_function(f, *args, **kwargs):
    myname = str(f).split(' ')[1]
    def new_func(*args, **kwargs):
        t = time.ticks_us()
        result = f(*args, **kwargs)
        delta = time.ticks_diff(time.ticks_us(), t)
        print('Function {} Time = {:6.3f}ms'.format(myname, delta/1000))
        return result
    return new_func

MicroPython コードの改良

const() 宣言

MicroPython は const() 宣言を提供します。これは C 言語の #define と同様に作用するもので、コードがバイトコードにコンパイルされるときにコンパイラが識別子を数値に置換します。これにより、実行時の辞書検索が回避されます。 const() の引数はコンパイル時に整数値に評価されるものであれば何でもありです(0x1001 << 8 など)。

オブジェクト参照のキャッシュ

関数またはメソッドが繰り返しオブジェクトにアクセスする場合、オブジェクトをローカル変数にキャッシュすることでパフォーマンスが向上します。

class foo(object):
    def __init__(self):
        self.ba = bytearray(100)
    def bar(self, obj_display):
        ba_ref = self.ba
        fb = obj_display.framebuffer
        # iterative code using these two objects

これは bar() メソッド本体で self.baobj_display.framebuffer を繰り返し探す必要性を回避します。

ガベージコレクションを制御する

メモリ割り当てが必要な場合、MicroPython はヒープ上で適切なサイズのブロックを見つけようとします。これは失敗する可能性があります。これは通常、コード内参照されなくなったオブジェクトでヒープが雑然としているためです。メモリ割り当てに失敗した場合、ガベージコレクションと呼ばれるプロセスが参照されなくなったオブジェクトが使っているメモリを回収し、割り当てが再試行されます。このプロセスには数ミリ秒かかることがあります。

gc.collect() を定期的に実行することで、ガベージコレクションを先取りすることに利点があるかもしれません。第1に、ガベージコレクションが実際に必要とされる前に行ってけばより速く終わります - 頻繁に行うならば通常 1ms のオーダーで終わります。第2に、ガベージコレクションで時間が消費されるコード内のポイントを決定でき、ランダムなポイント(おそらくスピードが重視されるセクション)でより長い遅延が発生するのを回避できます。最後に、定期的にガベージコレクションを実行すると、ヒープ内の断片化を減らすことができます。重大な断片化は、回復不可能な割り当ての失敗につながる可能性があります。

ネイティブコードエミッター

これにより、MicroPython コンパイラはバイトコードではなくネイティブの CPU オペコードを発行します。MicroPython の機能の大部分をカバーしているので、ほとんどの機能は調整を必要としません(ただし下記参照)。これは関数デコレータによって呼び出されます。

@micropython.native
def foo(self, arg):
    buf = self.linebuf # オブジェクトをキャッシュ
    # コード

ネイティブコードエミッターの現在の実装には一定の制限があります。

  • コンテキストマネージャはサポートされていません(with ステートメント)
  • ジェネレータはサポートされていません。
  • raise を使う場合は、引数を指定する必要があります。

パフォーマンスの向上(バイトコードの約2倍の速さ)とのトレードオフは、コンパイルされたコードサイズの増加です。

バイパーコードエミッター

先にとりあげた最適化には、標準に準拠した Python コードが含まれています。バイパーコードのエミッターは完全には準拠していません。パフォーマンスを追求した特別なバイパーネイティブデータ型をサポートします。整数処理が不適合となっていて、機械語を使っているため、32ビットハードウェア上での算術演算は 2**32 の範囲となります。

ネイティブエミッタと同様にバイパーは機械語命令を生成しますが、さらに最適化が実行され、特に整数演算とビット操作のパフォーマンスが大幅に向上します。これはデコレータを使って呼び出されます。

@micropython.viper
def foo(self, arg: int) -> int:
    # コード

上記のコードが示すように、バイパー最適化ツールを支援するために Python の型ヒントを使うことが有益です。型ヒントは、引数のデータ型と戻り値の情報を提供します。これは PEP0484 で正式に定義された標準の Python 言語機能です。バイパーは独自の型セット int, uint (符号なし整数), ptr, ptr8, ptr16, ptr32 をサポートしています。ptrX 型については後述します。現在のところ uint 型は単一の目的、関数の戻り値の型ヒントとして使うために提供しています。そのような関数が 0xffffffff を返す場合、Python は結果を -1 ではなく、2**32 -1 として解釈します。

バイパーでは、ネイティブエミッターの制限に加えて以下の制限が適用されます:

  • 関数が持てる引数は最大4つまでです。
  • デフォルトの引数値は許可されていません。
  • 浮動小数点は使えますが、最適化されません。

バイパーは最適化を支援するためのポインタ型を提供します。次のものがあります。

  • ptr オブジェクトのポインター
  • ptr8 バイトを指すポインター
  • ptr16 16ビット、半ワードを指すポインター
  • ptr32 32ビットマシンワードを指すポインター

ポインターの概念は、Python プログラマにはなじみがないかもしれません。 memoryview メモリに格納されているデータに直接アクセスできるという点で、Python オブジェクトと似ています。項目には添え字表記を使ってアクセスしますが、スライスはサポートしていません。ポインターは単一の項目のみを返すことができます。その目的は、バッファプロトコルをサポートするオブジェクトに格納されたデータや、マイクロコントローラ内のメモリマップされた周辺レジスタなど、連続したメモリ位置に格納されたデータへの高速ランダムアクセスを提供することです。ポインターを使用したプログラミングは危険です。境界チェックは実行されず、コンパイラはバッファオーバーランエラーを防ぐために何もしません。

典型的な使い方は変数をキャッシュすることです:

@micropython.viper
def foo(self, arg: int) -> int:
    buf = ptr8(self.linebuf) # self.linebuf は bytearray または bytes オブジェクト
    for x in range(20, 30):
        bar = buf[x] # ポインターでデータ項目にアクセス
        # 以下省略

この例において、コンパイラは buf がバイト配列のアドレスであることを「認識」しています。このため、 buf[x] 実行時にアドレスを迅速に計算するためのコードを発行できます。オブジェクトをバイパーネイティブ型に変換するのにキャストを使う場合、キャスト操作には数マイクロ秒かかることがあるため、これらはクリティカルなタイミングループではなく関数の開始時に実行する必要があります。キャストの規則は次のとおりです:

  • キャスト演算子には現在のところ int, bool, uint, ptr, ptr8, ptr16, ptr32 があります。
  • キャストの結果は、ネイティブのバイパー変数になります。
  • キャストの引数には、Python オブジェクトまたはネイティブのバイパー変数を指定できます。
  • 引数がネイティブバイパー変数である場合、単に型が変わるだけで(たとえば uint から ptr8)、キャストは何もしない(実行時にコストはかからない)ので、このポインターでストア/ロードできるようになります。
  • 引数が Python オブジェクトであり、キャストが int または uint である場合、Python オブジェクトは整数型でなければならず、その整数オブジェクトの値が返されます。
  • bool キャストへの引数は整数型(ブール値または整数)でなければなりません。戻り値の型として使われた場合、バイパー関数は True または False オブジェクトを返します。
  • 引数が Python オブジェクトであり、キャストが ptr, ptr16, ptr32 の場合、Python オブジェクトはバッファプロトコルを有しているか(バッファの先頭へのポインタを返す場合)、整数型のものでなければなりません(この場合、その整数オブジェクトの値が返されます)。

読取り専用オブジェクトを指すポインターへの書込みは、未定義の動作になります。

次はピン X1 を n 回切り替えるために ptr16 キャストを使った例を示しています

BIT0 = const(1)
@micropython.viper
def toggle_n(n: int):
    odr = ptr16(stm.GPIOA + stm.GPIO_ODR)
    for _ in range(n):
        odr[0] ^= BIT0

3つのコードエミッターの詳細な技術的説明が、Kickstarter の Note 1Note 2 にあります。

ハードウェアに直接アクセスする

注釈

この章のコード例は pyboard 用です。ただし、ここで説明した手法は他の MicroPython ポートにも適用できます。

ここからはより高度なプログラミングの範疇に入り、ターゲット MCU の知識が必要になります。pyboard で出力ピンを切り替える例を考えてみましょう。標準的なアプローチは、次のように書くことです。

mypin.value(mypin.value() ^ 1) # mypin は出力ピンとしてインスタンス化したもの

これには Pin "インスタンスの value() メソッドに対する2回の呼び出しのオーバーヘッドが含まれます。このオーバーヘッドは、チップの GPIO ポート出力データレジスタ(odr)の関連ビットへの読み書きを実行することで解消できます。これを容易にするために stm モジュールは関連レジスタのアドレスの定数のセットを提供します。緑色のLEDに対応するピン P4 (CPU ピン A14)の高速トグルは、次のように実行できます。

import machine
import stm

BIT14 = const(1 << 14)
machine.mem16[stm.GPIOA + stm.GPIO_ODR] ^= BIT14