Tối đa hóa tốc độ MicroPython¶
Hướng dẫn này mô tả các cách cải thiện hiệu năng của mã MicroPython. Các tối ưu hóa liên quan đến các ngôn ngữ khác được đề cập ở nơi khác, cụ thể là việc sử dụng các module viết bằng C và trình hợp dịch nội tuyến MicroPython.
Quá trình phát triển mã hiệu năng cao bao gồm các giai đoạn sau, cần được thực hiện theo thứ tự liệt kê.
Thiết kế cho tốc độ.
Viết mã và gỡ lỗi.
Các bước tối ưu hóa:
Xác định đoạn mã chậm nhất.
Cải thiện hiệu quả của mã Python.
Sử dụng trình phát mã native.
Sử dụng trình phát mã viper.
Sử dụng các tối ưu hóa đặc thù của phần cứng.
Thiết kế cho tốc độ¶
Các vấn đề về hiệu năng cần được xem xét ngay từ đầu. Điều này bao gồm việc nhìn nhận các phần mã có tính quan trọng về hiệu năng nhất và dành sự chú ý đặc biệt cho thiết kế của chúng. Quá trình tối ưu hóa bắt đầu khi mã đã được kiểm tra: nếu thiết kế đúng ngay từ đầu, việc tối ưu hóa sẽ đơn giản và thực ra có thể không cần thiết.
Thuật toán¶
Khía cạnh quan trọng nhất khi thiết kế bất kỳ hàm nào cho hiệu năng là đảm bảo sử dụng thuật toán tốt nhất. Đây là chủ đề dành cho sách giáo khoa hơn là hướng dẫn MicroPython, nhưng đôi khi có thể đạt được những cải thiện hiệu năng đáng kinh ngạc bằng cách áp dụng các thuật toán nổi tiếng về hiệu quả.
Phân bổ RAM¶
Để thiết kế mã MicroPython hiệu quả, cần phải hiểu cách trình thông dịch phân bổ RAM. Khi một đối tượng được tạo ra hoặc tăng kích thước (ví dụ khi một mục được thêm vào danh sách), RAM cần thiết được phân bổ từ một khối gọi là heap. Điều này tốn một lượng thời gian đáng kể; hơn nữa, đôi khi nó sẽ kích hoạt một quá trình gọi là thu gom rác, có thể mất vài mili giây.
Do đó, hiệu năng của một hàm hoặc phương thức có thể được cải thiện nếu một đối tượng được tạo ra chỉ một lần và không bị phép tăng kích thước. Điều này có nghĩa là đối tượng tồn tại trong suốt thời gian sử dụng: thông thường nó sẽ được khởi tạo trong hàm khởi tạo của lớp và được sử dụng trong các phương thức khác nhau.
Điều này được đề cập chi tiết hơn tại Kiểm soát thu gom rác bên dưới.
Bộ đệm¶
Một ví dụ về điều trên là trường hợp phổ biến khi cần một bộ đệm, chẳng hạn như một bộ đệm được sử dụng để giao tiếp với thiết bị. Một driver thông thường sẽ tạo bộ đệm trong hàm khởi tạo và sử dụng nó trong các phương thức I/O sẽ được gọi lặp đi lặp lại.
Các thư viện MicroPython thường cung cấp hỗ trợ cho các bộ đệm được phân bổ trước. Ví dụ, các đối tượng hỗ trợ giao diện luồng (ví dụ: tệp hoặc UART) cung cấp phương thức read() phân bổ bộ đệm mới cho dữ liệu đọc, nhưng cũng có phương thức readinto() để đọc dữ liệu vào bộ đệm hiện có.
Một số lớp hữu ích để tạo các đối tượng bộ đệm có thể tái sử dụng:
Số thực dấu phẩy động¶
Một số cổng MicroPython phân bổ số thực dấu phẩy động trên heap. Một số cổng khác có thể thiếu bộ đồng xử lý dấu phẩy động chuyên dụng và thực hiện các phép tính số học trên chúng bằng "phần mềm" với tốc độ chậm hơn đáng kể so với số nguyên. Khi hiệu năng quan trọng, hãy sử dụng các phép tính số nguyên và hạn chế việc sử dụng dấu phẩy động cho các phần mã không đòi hỏi hiệu năng cao. Ví dụ, thu thập các giá trị đọc ADC dưới dạng số nguyên vào một mảng một cách nhanh chóng, và chỉ sau đó mới chuyển đổi chúng sang số thực dấu phẩy động để xử lý tín hiệu.
Mảng¶
Hãy cân nhắc sử dụng các loại lớp mảng khác nhau như một thay thế cho danh sách. Module array hỗ trợ các loại phần tử khác nhau với các phần tử 8-bit được hỗ trợ bởi các lớp bytes và bytearray tích hợp sẵn của Python. Tất cả các cấu trúc dữ liệu này lưu trữ các phần tử ở các vị trí bộ nhớ liền kề. Một lần nữa để tránh phân bổ bộ nhớ trong mã quan trọng, chúng nên được phân bổ trước và truyền vào dưới dạng đối số hoặc đối tượng liên kết.
Memoryview¶
Khi truyền các lát cắt của đối tượng như các phiên bản bytearray, Python tạo ra một bản sao liên quan đến việc phân bổ kích thước tỷ lệ thuận với kích thước của lát cắt. Điều này có thể được giảm bớt bằng cách sử dụng đối tượng memoryview. Bản thân memoryview được phân bổ trên heap, nhưng là một đối tượng nhỏ, kích thước cố định, bất kể kích thước của lát cắt mà nó trỏ tới. Cắt một memoryview tạo ra một memoryview mới, vì vậy điều này không thể thực hiện trong một hàm phục vụ ngắt. Hơn nữa, cú pháp lát cắt a:b gây ra phân bổ thêm bằng cách khởi tạo đối tượng 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
Một memoryview chỉ có thể được áp dụng cho các đối tượng hỗ trợ giao thức bộ đệm - bao gồm mảng nhưng không phải danh sách. Lưu ý nhỏ là trong khi đối tượng memoryview còn hoạt động, nó cũng giữ cho đối tượng bộ đệm gốc còn sống. Vì vậy, memoryview không phải là giải pháp toàn diện. Ví dụ, trong ví dụ trên, nếu bạn đã xong với bộ đệm 10K và chỉ cần những byte 30:2000 từ nó, có thể tốt hơn là tạo một lát cắt và để bộ đệm 10K đi (sẵn sàng để thu gom rác), thay vì tạo một memoryview sống lâu và giữ 10K bị chặn cho GC.
Tuy nhiên, memoryview là không thể thiếu cho việc quản lý bộ đệm phân bổ trước nâng cao. Phương thức readinto() được thảo luận ở trên đặt dữ liệu vào đầu bộ đệm và điền vào toàn bộ bộ đệm. Điều gì xảy ra nếu bạn cần đặt dữ liệu vào giữa bộ đệm hiện có? Chỉ cần tạo một memoryview vào phần cần thiết của bộ đệm và truyền nó cho readinto().
Chuỗi so với Bytes¶
MicroPython sử dụng chuỗi nội tuyến để tiết kiệm không gian khi có nhiều chuỗi giống nhau. Mỗi lần một chuỗi mới được phân bổ khi chạy (ví dụ, khi hai chuỗi khác được nối), MicroPython kiểm tra xem chuỗi mới có thể được nội tuyến để tiết kiệm RAM hay không.
Nếu bạn có mã thực hiện các thao tác chuỗi quan trọng về hiệu năng, hãy cân nhắc sử dụng các đối tượng và chữ ký bytes (tức là b"abc"). Điều này bỏ qua việc kiểm tra nội tuyến và có thể nhanh hơn vài lần so với thực hiện cùng các thao tác với đối tượng chuỗi.
Ghi chú
Hiệu năng nhanh nhất luôn đạt được bằng cách tránh hoàn toàn việc tạo đối tượng mới, ví dụ với bộ đệm có thể tái sử dụng như mô tả ở trên.
Xác định đoạn mã chậm nhất¶
Đây là một quá trình được gọi là phân tích hiệu năng và được đề cập trong sách giáo khoa và (đối với Python chuẩn) được hỗ trợ bởi nhiều công cụ phần mềm. Đối với loại ứng dụng nhúng nhỏ hơn có khả năng chạy trên các nền tảng MicroPython, hàm hoặc phương thức chậm nhất thường có thể được xác định bằng cách sử dụng khéo léo nhóm hàm ticks của bộ định thời được ghi lại trong time. Thời gian thực thi mã có thể được đo bằng ms, us hoặc chu kỳ CPU.
Phần sau đây cho phép bất kỳ hàm hoặc phương thức nào được đo thời gian bằng cách thêm một decorator @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
Cải tiến mã MicroPython¶
Khai báo const()¶
MicroPython cung cấp khai báo const(). Nó hoạt động tương tự như #define trong C ở chỗ khi mã được biên dịch thành bytecode, trình biên dịch thay thế giá trị số cho định danh. Điều này tránh việc tra cứu từ điển khi chạy. Đối số cho const() có thể là bất cứ thứ gì mà tại thời điểm biên dịch, đánh giá thành số nguyên, ví dụ 0x100 hoặc 1 << 8.
Lưu trữ tham chiếu đối tượng vào bộ nhớ đệm¶
Khi một hàm hoặc phương thức truy cập đối tượng nhiều lần, hiệu năng được cải thiện bằng cách lưu trữ đối tượng vào một biến cục bộ:
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
Điều này tránh việc phải tra cứu self.ba và obj_display.framebuffer nhiều lần trong phần thân của phương thức bar().
Kiểm soát thu gom rác¶
Khi cần phân bổ bộ nhớ, MicroPython cố gắng tìm một khối có kích thước phù hợp trên heap. Điều này có thể thất bại, thường là do heap bị lộn xộn với các đối tượng không còn được mã tham chiếu nữa. Nếu xảy ra lỗi, quá trình thu gom rác lấy lại bộ nhớ được sử dụng bởi các đối tượng dư thừa này và sau đó thử lại việc phân bổ - một quá trình có thể mất vài mili giây.
Có thể có lợi khi phòng ngừa điều này bằng cách định kỳ gọi gc.collect(). Thứ nhất, thực hiện thu gom trước khi thực sự cần sẽ nhanh hơn - thường khoảng 1ms nếu thực hiện thường xuyên. Thứ hai, bạn có thể xác định điểm trong mã nơi thời gian này được sử dụng thay vì có một độ trễ lâu hơn xảy ra tại các điểm ngẫu nhiên, có thể trong một phần quan trọng về tốc độ. Cuối cùng, thực hiện thu gom thường xuyên có thể giảm phân mảnh trong heap. Phân mảnh nghiêm trọng có thể dẫn đến các lỗi phân bổ không thể phục hồi.
Trình phát mã Native¶
Điều này khiến trình biên dịch MicroPython phát ra các mã lệnh CPU native thay vì bytecode. Nó bao gồm phần lớn chức năng MicroPython, vì vậy hầu hết các hàm sẽ không cần điều chỉnh (nhưng xem bên dưới). Nó được kích hoạt bằng một decorator hàm:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Có một số hạn chế nhất định trong việc triển khai hiện tại của trình phát mã native.
Nếu sử dụng
raise, phải cung cấp một đối số.Bộ lập lịch nền (xem
micropython.schedule) không chạy trong quá trình thực thi mã native.Trên các mục tiêu có luồng và GIL, GIL không được giải phóng trong quá trình thực thi mã native.
Để giảm thiểu hai điểm cuối, các hàm native chạy lâu nên gọi time.sleep(0) định kỳ, điều này sẽ chạy bộ lập lịch và giải phóng GIL.
Sự đánh đổi cho hiệu năng được cải thiện (nhanh gấp đôi so với bytecode) là sự tăng kích thước mã được biên dịch.
Trình phát mã Viper¶
Các tối ưu hóa được thảo luận ở trên liên quan đến mã Python tuân thủ chuẩn. Trình phát mã Viper không hoàn toàn tuân thủ. Nó hỗ trợ các kiểu dữ liệu native đặc biệt của Viper để theo đuổi hiệu năng. Xử lý số nguyên không tuân thủ vì nó sử dụng các từ máy: phép tính số học trên phần cứng 32 bit được thực hiện theo modulo 2**32.
Giống như trình phát Native, Viper tạo ra các lệnh máy nhưng thực hiện thêm các tối ưu hóa, tăng đáng kể hiệu năng đặc biệt cho phép tính số học số nguyên và thao tác bit. Nó được kích hoạt bằng một decorator:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Như đoạn trên minh họa, việc sử dụng các gợi ý kiểu Python để hỗ trợ bộ tối ưu hóa Viper rất có lợi. Gợi ý kiểu cung cấp thông tin về các kiểu dữ liệu của đối số và giá trị trả về; đây là một tính năng ngôn ngữ Python chuẩn được định nghĩa chính thức tại đây PEP0484. Viper hỗ trợ bộ kiểu riêng của mình, cụ thể là int, uint (số nguyên không dấu), ptr, ptr8, ptr16 và ptr32. Các kiểu ptrX được thảo luận bên dưới. Hiện tại kiểu uint phục vụ một mục đích duy nhất: làm gợi ý kiểu cho giá trị trả về của hàm. Nếu một hàm như vậy trả về 0xffffffff, Python sẽ diễn giải kết quả là 2**32 -1 thay vì -1.
Ngoài các hạn chế áp đặt bởi trình phát native, các ràng buộc sau áp dụng:
Các giá trị đối số mặc định không được phép.
Dấu phẩy động có thể được sử dụng nhưng không được tối ưu hóa.
Viper cung cấp các kiểu con trỏ để hỗ trợ bộ tối ưu hóa. Chúng bao gồm
ptrCon trỏ đến một đối tượng.ptr8Trỏ đến một byte.ptr16Trỏ đến một nửa từ 16 bit.ptr32Trỏ đến một từ máy 32 bit.
Khái niệm con trỏ có thể xa lạ với các lập trình viên Python. Nó có sự tương đồng với đối tượng memoryview của Python ở chỗ nó cung cấp quyền truy cập trực tiếp vào dữ liệu được lưu trữ trong bộ nhớ. Các mục được truy cập bằng ký hiệu chỉ số, nhưng các lát cắt không được hỗ trợ: một con trỏ chỉ có thể trả về một mục duy nhất. Mục đích của nó là cung cấp quyền truy cập ngẫu nhiên nhanh vào dữ liệu được lưu trữ ở các vị trí bộ nhớ liền kề - chẳng hạn như dữ liệu được lưu trữ trong các đối tượng hỗ trợ giao thức bộ đệm và các thanh ghi ngoại vi được ánh xạ bộ nhớ trong vi điều khiển. Cần lưu ý rằng lập trình sử dụng con trỏ là nguy hiểm: kiểm tra ranh giới không được thực hiện và trình biên dịch không làm gì để ngăn chặn các lỗi tràn bộ đệm.
Cách sử dụng điển hình là lưu trữ các biến vào bộ nhớ đệm:
@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
Trong trường hợp này, trình biên dịch "biết" rằng buf là địa chỉ của một mảng byte; nó có thể phát ra mã để tính toán nhanh địa chỉ của buf[x] khi chạy. Khi các phép chuyển đổi kiểu được sử dụng để chuyển đổi đối tượng sang các kiểu native Viper, chúng nên được thực hiện ở đầu hàm thay vì trong các vòng lặp thời gian quan trọng vì thao tác chuyển đổi kiểu có thể mất vài micro giây. Các quy tắc cho việc chuyển đổi kiểu như sau:
Các toán tử chuyển đổi kiểu hiện tại là:
int,bool,uint,ptr,ptr8,ptr16vàptr32.Kết quả của một phép chuyển đổi kiểu sẽ là một biến native Viper.
Đối số cho một phép chuyển đổi kiểu có thể là một đối tượng Python hoặc một biến native Viper.
Nếu đối số là một biến native Viper, thì phép chuyển đổi kiểu là một thao tác không làm gì (tức là không tốn chi phí khi chạy) chỉ thay đổi kiểu (ví dụ từ
uintsangptr8) để bạn có thể lưu/tải bằng con trỏ này.Nếu đối số là một đối tượng Python và phép chuyển đổi kiểu là
inthoặcuint, thì đối tượng Python phải thuộc kiểu số nguyên và giá trị của đối tượng số nguyên đó được trả về.Đối số cho phép chuyển đổi kiểu bool phải thuộc kiểu số nguyên (boolean hoặc integer); khi được sử dụng làm kiểu trả về, hàm viper sẽ trả về các đối tượng True hoặc False.
Nếu đối số là một đối tượng Python và phép chuyển đổi kiểu là
ptr,ptr8,ptr16hoặcptr32, thì đối tượng Python phải có giao thức bộ đệm (trong trường hợp đó một con trỏ đến đầu bộ đệm được trả về) hoặc phải thuộc kiểu số nguyên (trong trường hợp đó giá trị của đối tượng số nguyên đó được trả về).
Ghi vào một con trỏ trỏ đến một đối tượng chỉ đọc sẽ dẫn đến hành vi không xác định.
Ghi chú
Các ví dụ mã bên dưới được đưa ra cho các OpenMV Cam dựa trên STM32, cung cấp module stm. Các kỹ thuật được mô tả áp dụng chung.
Module stm hiển thị các địa chỉ bộ nhớ của các thanh ghi ngoại vi của MCU. Mỗi cổng GPIO có một thanh ghi dữ liệu đầu ra (ODR) mà các bit của nó ánh xạ một-một đến các chân của cổng đó: ghi vào thanh ghi điều khiển trực tiếp các chân đó, không có chi phí của một lần gọi phương thức machine.Pin, và XOR một bit sẽ chuyển đổi chân của nó. Trên OpenMV Cam gốc, LED xanh dương được nối với chân 2 của GPIOC, vì vậy ví dụ sau sử dụng phép chuyển đổi kiểu ptr16 để bật tắt LED xanh dương n lầ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
Một mô tả kỹ thuật chi tiết về ba trình phát mã có thể được tìm thấy trên Kickstarter tại đây Note 1 và tại đây Note 2
Truy cập phần cứng trực tiếp¶
Điều này thuộc loại lập trình nâng cao hơn và đòi hỏi một số kiến thức về MCU mục tiêu. Hãy xem xét ví dụ về việc chuyển đổi một chân đầu ra trên OpenMV Cam. Cách tiếp cận chuẩn sẽ là viết
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Điều này liên quan đến chi phí của hai lần gọi phương thức value() của phiên bản Pin. Chi phí này có thể được loại bỏ bằng cách thực hiện đọc/ghi vào bit liên quan của thanh ghi dữ liệu đầu ra cổng GPIO của chip (ODR). Để hỗ trợ điều này, module stm cung cấp một tập hợp các hằng số đưa ra các địa chỉ của các thanh ghi liên quan (stm.GPIOC là địa chỉ cơ sở của cổng GPIOC, stm.GPIO_ODR là offset của thanh ghi dữ liệu đầu ra của nó). Như trên, LED xanh dương trên OpenMV Cam gốc là chân 2 của GPIOC, vì vậy có thể thực hiện chuyển đổi nhanh như sau:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2