MicroPython の速度を最大化する¶
このチュートリアルでは、MicroPython コードのパフォーマンスを改善する方法について説明します。他の言語に関わる最適化、すなわち C で記述したモジュールの使用や MicroPython のインラインアセンブラについては、別の箇所で扱います。
高性能なコードを開発するプロセスは、以下に列挙する各段階から構成され、記載した順序で実施する必要があります。
速度を考慮した設計を行う。
コードを書いてデバッグする。
最適化の手順:
最も遅いコード区間を特定する。
Python コードの効率を改善する。
ネイティブコードエミッタを使用する。
Viper コードエミッタを使用する。
ハードウェア固有の最適化を使用する。
速度を考慮した設計¶
パフォーマンスの問題は最初から考慮しておくべきです。これは、パフォーマンスが最も重要なコード区間を見極め、その設計に特に注意を払うことを意味します。最適化のプロセスは、コードのテストが済んでから始まります。最初の段階で設計が正しければ、最適化は容易になり、実際には不要となる場合もあります。
アルゴリズム¶
パフォーマンスを目的としてルーチンを設計する上で最も重要なのは、最良のアルゴリズムを採用することです。これは MicroPython ガイドというよりは教科書で扱うべき話題ですが、効率の良さで知られるアルゴリズムを採用することで、目覚ましいパフォーマンス向上が得られることがあります。
RAM の割り当て¶
効率的な MicroPython コードを設計するには、インタプリタが RAM を割り当てる仕組みを理解しておく必要があります。オブジェクトが生成されたりサイズが増大したりすると(例えばリストに要素が追加されたとき)、必要な RAM はヒープと呼ばれるブロックから割り当てられます。これにはかなりの時間がかかり、さらに場合によってはガベージコレクションと呼ばれるプロセスがトリガーされ、これには数ミリ秒かかることがあります。
そのため、オブジェクトを一度だけ生成し、サイズを増大させないようにすれば、関数やメソッドのパフォーマンスを改善できます。これは、そのオブジェクトを使用する期間中ずっと存続させることを意味します。一般的には、クラスのコンストラクタでインスタンス化し、さまざまなメソッドで使用します。
これについては、後述の ガベージコレクションの制御 でさらに詳しく扱います。
バッファ¶
上記の一例は、デバイスとの通信に使用するものなど、バッファが必要となる一般的なケースです。典型的なドライバは、コンストラクタでバッファを生成し、繰り返し呼び出される I/O メソッド内でそれを使用します。
MicroPython のライブラリは通常、事前割り当てされたバッファのサポートを提供します。例えば、ストリームインターフェースをサポートするオブジェクト(ファイルや UART など)は、読み込んだデータ用に新しいバッファを割り当てる read() メソッドだけでなく、既存のバッファにデータを読み込む readinto() メソッドも提供します。
再利用可能なバッファオブジェクトを生成するのに便利なクラスをいくつか挙げます:
浮動小数点¶
一部の MicroPython ポートは、浮動小数点数をヒープ上に割り当てます。また、専用の浮動小数点コプロセッサを持たず、浮動小数点の算術演算を整数よりもかなり低速な「ソフトウェア」で行うポートもあります。パフォーマンスが重要な場合は整数演算を使用し、浮動小数点の使用はパフォーマンスが最重要でないコード区間に限定してください。例えば、ADC の読み取り値を整数値として一度にすばやく配列に取り込み、その後でのみ信号処理のために浮動小数点数へ変換するとよいでしょう。
配列¶
リストの代替として、各種の配列クラスの使用を検討してください。array モジュールはさまざまな要素型をサポートしており、8 ビット要素は Python 組み込みの bytes クラスと bytearray クラスでサポートされます。これらのデータ構造はいずれも要素を連続したメモリ位置に格納します。ここでも、重要なコードでのメモリ割り当てを避けるため、これらは事前に割り当て、引数として、あるいはバインドされたオブジェクトとして渡すべきです。
Memoryview¶
bytearray インスタンスなどのオブジェクトのスライスを渡すと、Python はコピーを生成し、これにはスライスのサイズに比例した割り当てが伴います。これは memoryview オブジェクトを使うことで軽減できます。memoryview 自体はヒープ上に割り当てられますが、参照先のスライスのサイズに関係なく、小さく固定サイズのオブジェクトです。memoryview をスライスすると新しい memoryview が生成されるため、これは割り込みサービスルーチン内では行えません。さらに、スライス構文 a:b は slice(a, b) オブジェクトをインスタンス化することで、さらなる割り当てを引き起こします。
ba = bytearray(10000) # big array
func(ba[30:2000]) # a copy is passed, ~2K new allocation
mv = memoryview(ba) # small object is allocated
func(mv[30:2000]) # a pointer to memory is passed
memoryview はバッファプロトコルをサポートするオブジェクトにのみ適用できます。これには配列は含まれますがリストは含まれません。注意点として、memoryview オブジェクトが生きている間は、元のバッファオブジェクトも生かし続けます。したがって、memoryview は万能薬ではありません。例えば、上記の例で 10K のバッファを使い終わり、そこから 30:2000 のバイトだけが必要な場合は、長く生存する memoryview を作って 10K を GC のためにブロックし続けるよりも、スライスを作って 10K バッファを手放す(ガベージコレクションの対象になるようにする)方が良いかもしれません。
とはいえ、memoryview は高度な事前割り当てバッファ管理には欠かせません。前述の readinto() メソッドはデータをバッファの先頭に置き、バッファ全体を埋めます。既存のバッファの途中にデータを置く必要がある場合はどうすればよいでしょうか。バッファの必要な区間への memoryview を作成し、それを readinto() に渡すだけです。
文字列とバイト列¶
MicroPython は、同一の文字列が複数存在する場合に領域を節約するため、文字列インターン を使用します。実行時に新しい文字列が割り当てられるたびに(例えば 2 つの文字列が連結されたとき)、MicroPython は RAM を節約するためにその新しい文字列をインターンできるかどうかを確認します。
パフォーマンスが重要な文字列操作を行うコードがある場合は、bytes オブジェクトやリテラル(すなわち b"abc")の使用を検討してください。これによりインターンの確認がスキップされ、同じ操作を文字列オブジェクトで行う場合よりも数倍高速になることがあります。
注釈
最速のパフォーマンスは、例えば 前述の再利用可能なバッファ を使うなど、新しいオブジェクトの生成を完全に避けることで常に達成されます。
最も遅いコード区間を特定する¶
これはプロファイリングと呼ばれるプロセスであり、教科書で扱われ、(標準の 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() への引数は、コンパイル時に整数に評価されるものなら何でもよく、例えば 0x100 や 1 << 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.ba と obj_display.framebuffer を繰り返し検索する必要がなくなります。
ガベージコレクションの制御¶
メモリ割り当てが必要になると、MicroPython はヒープ上で十分なサイズのブロックを見つけようとします。これは失敗することがあり、通常はコードからもう参照されなくなったオブジェクトでヒープが散らかっているためです。失敗が発生すると、ガベージコレクションと呼ばれるプロセスがこれらの不要なオブジェクトが使用していたメモリを回収し、その後で割り当てが再試行されます。これには数ミリ秒かかることがあります。
定期的に gc.collect() を発行してこれを先取りすることには利点がある場合があります。第一に、実際に必要になる前にコレクションを行う方が高速です。頻繁に行えば通常 1ms 程度です。第二に、ランダムな箇所で(場合によっては速度が重要な区間で)長い遅延が発生するのではなく、この時間が使われるコード上の地点を自分で決められます。最後に、コレクションを定期的に行うことでヒープの断片化を軽減できます。深刻な断片化は回復不能な割り当て失敗につながることがあります。
ネイティブコードエミッタ¶
これにより、MicroPython コンパイラはバイトコードではなくネイティブの CPU オペコードを出力するようになります。MicroPython の機能の大部分をカバーするため、ほとんどの関数は適応が不要です(ただし以下を参照)。これは関数デコレータによって呼び出します:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
現在のネイティブコードエミッタの実装には、いくつかの制限があります。
raiseを使用する場合は、引数を指定する必要があります。ネイティブコードの実行中は、バックグラウンドスケジューラ(
micropython.scheduleを参照)が実行されません。スレッドと GIL を持つターゲットでは、ネイティブコードの実行中に GIL が解放されません。
後者の 2 点を緩和するため、長時間実行されるネイティブ関数は定期的に time.sleep(0) を呼び出すべきです。これによりスケジューラが実行され、GIL が一時的に解放されます。
パフォーマンス向上(バイトコードのおよそ 2 倍の速度)と引き換えに、コンパイル後のコードサイズが増加します。
Viper コードエミッタ¶
上で述べた最適化は、標準準拠の Python コードに関わるものです。Viper コードエミッタは完全には準拠していません。パフォーマンスを追求するために、Viper 独自のネイティブデータ型をサポートします。整数処理はマシンワードを使用するため非準拠です。32 ビットハードウェアでの算術演算は 2**32 を法として行われます。
ネイティブエミッタと同様に Viper はマシン命令を生成しますが、さらに最適化が行われ、特に整数算術とビット操作のパフォーマンスが大幅に向上します。これはデコレータを使って呼び出します:
@micropython.viper
def foo(self, arg: int) -> int:
# code
上記の断片が示すように、Viper オプティマイザを補助するために Python の型ヒントを使うと有益です。型ヒントは引数や戻り値のデータ型に関する情報を提供します。これは標準的な Python 言語の機能で、PEP0484 で正式に定義されています。Viper は独自の型のセット、すなわち int、uint(符号なし整数)、ptr、ptr8、ptr16、ptr32 をサポートします。ptrX 型については後述します。現在、uint 型は単一の目的、すなわち関数の戻り値の型ヒントとして機能します。そのような関数が 0xffffffff を返すと、Python はその結果を -1 ではなく 2**32 -1 と解釈します。
ネイティブエミッタによって課される制限に加えて、以下の制約が適用されます:
デフォルトの引数値は許可されません。
浮動小数点は使用できますが、最適化はされません。
Viper はオプティマイザを補助するためにポインタ型を提供します。これらは以下から構成されます
ptrオブジェクトへのポインタ。ptr8バイトを指します。ptr1616 ビットのハーフワードを指します。ptr3232 ビットのマシンワードを指します。
ポインタという概念は Python プログラマには馴染みがないかもしれません。これは、メモリに格納されたデータへの直接アクセスを提供するという点で、Python の memoryview オブジェクトに似ています。要素は添字記法を使ってアクセスしますが、スライスはサポートされません。ポインタは単一の要素のみを返すことができます。その目的は、連続したメモリ位置に格納されたデータ(バッファプロトコルをサポートするオブジェクトに格納されたデータや、マイクロコントローラのメモリマップされたペリフェラルレジスタなど)への高速なランダムアクセスを提供することです。ポインタを使ったプログラミングは危険であることに注意してください。境界チェックは行われず、コンパイラはバッファオーバーランエラーを防ぐために何もしません。
典型的な用途は変数のキャッシュです:
@micropython.viper
def foo(self, arg: int) -> int:
buf = ptr8(self.linebuf) # self.linebuf is a bytearray or bytes object
for x in range(20, 30):
bar = buf[x] # Access a data item through the pointer
# code omitted
この場合、コンパイラは buf がバイト配列のアドレスであることを「知って」います。そのため、実行時に buf[x] のアドレスを高速に計算するコードを出力できます。オブジェクトを Viper のネイティブ型に変換するためにキャストを使う場合、キャスト操作には数マイクロ秒かかることがあるため、これらはタイミングが重要なループ内ではなく関数の冒頭で行うべきです。キャストの規則は以下のとおりです:
現在のキャスト演算子は
int、bool、uint、ptr、ptr8、ptr16、ptr32です。キャストの結果は Viper のネイティブ変数になります。
キャストへの引数は Python オブジェクトか Viper のネイティブ変数のいずれかです。
引数が Viper のネイティブ変数の場合、キャストは何もしない操作(すなわち実行時のコストがゼロ)であり、型を変えるだけ(例えば
uintからptr8へ)で、そのポインタを使って格納や読み込みができるようになります。引数が Python オブジェクトで、キャストが
intまたはuintの場合、その Python オブジェクトは整数型でなければならず、その整数オブジェクトの値が返されます。bool キャストへの引数は整数型(ブール値または整数)でなければなりません。戻り値の型として使用した場合、viper 関数は True または False オブジェクトを返します。
引数が Python オブジェクトで、キャストが
ptr、ptr8、ptr16、ptr32のいずれかの場合、その Python オブジェクトはバッファプロトコルを持つ(その場合はバッファの先頭へのポインタが返されます)か、整数型である(その場合はその整数オブジェクトの値が返されます)かのいずれかでなければなりません。
読み取り専用オブジェクトを指すポインタへの書き込みは、未定義動作につながります。
注釈
以下のコード例は、stm モジュールを提供する STM32 ベースの OpenMV Cam 向けに記載されています。説明されている手法は一般的に適用できます。
stm モジュールは MCU のペリフェラルレジスタのメモリアドレスを公開します。各 GPIO ポートには 出力データレジスタ(ODR)があり、そのビットはそのポートのピンに 1 対 1 で対応しています。レジスタへの書き込みは、machine.Pin メソッド呼び出しのオーバーヘッドなしにそれらのピンを直接駆動し、ビットの XOR を取るとそのピンがトグルします。オリジナルの OpenMV Cam では青色 LED が GPIOC のピン 2 に配線されているため、次の例では ptr16 キャストを使って青色 LED を n 回トグルします:
BIT2 = const(1 << 2)
@micropython.viper
def toggle_n(n: int):
odr = ptr16(stm.GPIOC + stm.GPIO_ODR)
for _ in range(n):
odr[0] ^= BIT2
3 つのコードエミッタの詳細な技術的説明は、Kickstarter のこちら Note 1 およびこちら Note 2 で見ることができます
ハードウェアへの直接アクセス¶
これはより高度なプログラミングの範疇に入り、ターゲット MCU に関するある程度の知識を必要とします。OpenMV Cam で出力ピンをトグルする例を考えてみましょう。標準的なアプローチでは次のように書きます
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
これには、Pin インスタンスの value() メソッドへの 2 回の呼び出しというオーバーヘッドが伴います。このオーバーヘッドは、チップの GPIO ポート出力データレジスタ(ODR)の該当ビットに対して読み書きを行うことで除去できます。これを容易にするため、stm モジュールは該当レジスタのアドレスを与える一連の定数を提供します(stm.GPIOC は GPIOC ポートのベースアドレス、stm.GPIO_ODR はその出力データレジスタのオフセットです)。上記のとおり、オリジナルの OpenMV Cam の青色 LED は GPIOC のピン 2 なので、その高速なトグルは次のように行えます:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2