MicroPython 속도 극대화¶
이 튜토리얼은 MicroPython 코드의 성능을 개선하는 방법을 설명합니다. 다른 언어를 사용하는 최적화, 즉 C로 작성된 모듈의 사용과 MicroPython 인라인 어셈블러는 다른 곳에서 다룹니다.
고성능 코드를 개발하는 과정은 다음 단계로 구성되며, 나열된 순서대로 수행해야 합니다.
속도를 고려한 설계.
코딩 및 디버깅.
최적화 단계:
가장 느린 코드 구간을 식별합니다.
Python 코드의 효율을 개선합니다.
네이티브 코드 이미터를 사용합니다.
Viper 코드 이미터를 사용합니다.
하드웨어별 최적화를 사용합니다.
속도를 고려한 설계¶
성능 문제는 처음부터 고려해야 합니다. 이는 성능에 가장 중요한 코드 구간을 파악하고 그 설계에 특별한 주의를 기울이는 것을 의미합니다. 최적화 과정은 코드가 테스트된 후에 시작됩니다. 처음부터 설계가 올바르다면 최적화는 간단할 것이며, 실제로는 불필요할 수도 있습니다.
알고리즘¶
성능을 위해 어떤 루틴을 설계할 때 가장 중요한 측면은 최적의 알고리즘이 사용되도록 보장하는 것입니다. 이는 MicroPython 가이드보다는 교과서에서 다룰 주제이지만, 효율성으로 잘 알려진 알고리즘을 채택함으로써 때로는 놀라운 성능 향상을 얻을 수 있습니다.
RAM 할당¶
효율적인 MicroPython 코드를 설계하려면 인터프리터가 RAM을 할당하는 방식을 이해할 필요가 있습니다. 객체가 생성되거나 크기가 커질 때(예: 리스트에 항목이 추가될 때) 필요한 RAM은 힙(heap)이라고 알려진 블록에서 할당됩니다. 이 작업은 상당한 시간이 소요되며, 나아가 때때로 가비지 컬렉션(garbage collection)이라고 하는 과정을 유발하는데, 이는 수 밀리초가 걸릴 수 있습니다.
결과적으로 객체가 한 번만 생성되고 크기가 커지지 않도록 하면 함수나 메서드의 성능을 개선할 수 있습니다. 이는 객체가 사용되는 동안 지속됨을 의미합니다. 일반적으로 클래스 생성자에서 인스턴스화되어 여러 메서드에서 사용됩니다.
이에 대해서는 아래 가비지 컬렉션 제어 에서 더 자세히 다룹니다.
버퍼¶
위의 예로는 장치와의 통신에 사용되는 버퍼처럼 버퍼가 필요한 흔한 경우가 있습니다. 일반적인 드라이버는 생성자에서 버퍼를 생성하고 반복적으로 호출되는 입출력 메서드에서 이를 사용합니다.
MicroPython 라이브러리는 일반적으로 사전 할당된 버퍼에 대한 지원을 제공합니다. 예를 들어, 스트림 인터페이스를 지원하는 객체(예: 파일 또는 UART)는 읽은 데이터를 위해 새 버퍼를 할당하는 read() 메서드뿐만 아니라 기존 버퍼로 데이터를 읽어들이는 readinto() 메서드도 제공합니다.
재사용 가능한 버퍼 객체를 생성하는 데 유용한 몇 가지 클래스:
부동소수점¶
일부 MicroPython 포트는 부동소수점 숫자를 힙에 할당합니다. 일부 다른 포트는 전용 부동소수점 코프로세서가 없어 정수보다 상당히 느린 속도로 “소프트웨어”에서 부동소수점 산술 연산을 수행할 수 있습니다. 성능이 중요한 경우 정수 연산을 사용하고 부동소수점의 사용은 성능이 가장 중요하지 않은 코드 구간으로 제한하십시오. 예를 들어, ADC 읽기값을 한 번에 빠르게 정수 값으로 배열에 캡처한 다음, 그것을 신호 처리를 위해 부동소수점 숫자로 변환하십시오.
배열¶
리스트의 대안으로 다양한 유형의 배열 클래스를 사용하는 것을 고려하십시오. array 모듈은 다양한 요소 유형을 지원하며, 8비트 요소는 Python의 내장 bytes 및 bytearray 클래스에서 지원됩니다. 이러한 데이터 구조는 모두 요소를 연속된 메모리 위치에 저장합니다. 다시 한번, 중요한 코드에서 메모리 할당을 피하기 위해 이들은 사전 할당되어 인수로 또는 바인딩된 객체로 전달되어야 합니다.
메모리뷰¶
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() 에 전달하기만 하면 됩니다.
문자열 vs 바이트¶
MicroPython은 동일한 문자열이 여러 개 있을 때 공간을 절약하기 위해 문자열 인터닝 을 사용합니다. 런타임에 새 문자열이 할당될 때마다(예: 두 개의 다른 문자열이 연결될 때) 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이 해제되지 않습니다.
마지막 두 가지 사항을 완화하려면, 오래 실행되는 네이티브 함수는 주기적으로 time.sleep(0) 을 호출해야 하며, 이는 스케줄러를 실행하고 GIL을 양보(bounce)합니다.
향상된 성능(바이트코드보다 대략 두 배 빠름)에 대한 대가는 컴파일된 코드 크기의 증가입니다.
Viper 코드 이미터¶
위에서 논의한 최적화는 표준을 준수하는 Python 코드를 다룹니다. Viper 코드 이미터는 완전히 표준을 준수하지는 않습니다. 성능을 추구하기 위해 특수한 Viper 네이티브 데이터 유형을 지원합니다. 정수 처리는 머신 워드를 사용하기 때문에 표준을 준수하지 않습니다. 32비트 하드웨어에서의 산술 연산은 modulo 2**32로 수행됩니다.
네이티브 이미터와 마찬가지로 Viper는 머신 명령어를 생성하지만 추가적인 최적화가 수행되어, 특히 정수 산술과 비트 조작에서 성능을 상당히 향상시킵니다. 데코레이터를 사용하여 호출됩니다:
@micropython.viper
def foo(self, arg: int) -> int:
# code
위 코드 조각이 보여주듯이 Python 타입 힌트를 사용하여 Viper 최적화기를 돕는 것이 유익합니다. 타입 힌트는 인수와 반환 값의 데이터 유형에 대한 정보를 제공합니다. 이들은 표준 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)가 있습니다. 이 레지스터에 쓰면 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
세 가지 코드 이미터에 대한 자세한 기술 설명은 Kickstarter의 여기 Note 1 와 여기 Note 2 에서 찾을 수 있습니다
하드웨어에 직접 접근하기¶
이는 더 고급 프로그래밍 범주에 속하며 타깃 MCU에 대한 약간의 지식을 수반합니다. OpenMV Cam에서 출력 핀을 토글하는 예를 생각해 보십시오. 표준적인 접근 방식은 다음과 같이 작성하는 것입니다
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
이는 Pin 인스턴스의 value() 메서드를 두 번 호출하는 오버헤드를 수반합니다. 이 오버헤드는 칩의 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