Optimization – Tối ưu hóa hiệu năng trong lập trình Vi điều khiển

Tối ưu hóa (optimization) là một thuật ngữ rất quan trọng trong lập trình nhúng, đặc biệt trong lập trình Vi điều khiển. Làm việc với những chương trình có độ phức tạp cao nhưng tài nguyên trên nền tảng thiết bị phần cứng lại có nhiều hạn chế, bạn sẽ càng thấu hiểu được sự quan trọng của việc tối ưu chương trình.

Tại bài viết này, chúng ta sẽ cùng nhau tìm hiểu tổng quan về tối ưu hóa, các tùy chọn (option) được GCC hỗ trợ và thực hành với ví dụ cụ thể trên STM32.

1. Giới thiệu về tối ưu hóa

Việc tối ưu có thể được triển khai theo 2 cách:

  • Tối ưu thủ công
  • Tối ưu qua trình biên dịch

1.1. Tối ưu thủ công

Người lập trình chủ động phân tích, đánh giá và tự hiệu chỉnh mã nguồn một cách thủ công. Một số phương thức thường được áp dụng như:

  • Loại bỏ mã chết (Dead-code): Dead-code có thể là biến, lệnh, khối lệnh, hàm,… và sự tồn tại của chúng hoàn toàn không làm ảnh hưởng đến kết quả của chương trình
  • Sử dụng các cấu trúc dữ liệu hiệu quả đối với nhu cầu truy xuất dữ liệu của bài toán
  • Phân tích và lựa chọn thuật toán phù hợp và hiệu quả
  • Quản lý bộ nhớ bằng cách giải phóng những phân vùng bộ nhớ không còn được sử dụng sau khi cấp phát.

Tối ưu thủ công là một trong những kỹ năng quan trọng cần có của người lập trình Nhúng vi điều khiển. Để có kỹ năng tốt về tối ưu thủ công, chúng ta cần hiểu được cấu trúc dữ liệu, chương trình thuật toán và kiến trúc bộ nhớ trên thiết bị phần cứng trong dự án của các bạn. 

1.2. Tối ưu qua trình biên dịch

Là quá trình trình biên dịch tự động thực hiện các thay đổi đối với mã nguồn để cải thiện hiệu suất chương trình. Bản chất của loại tối ưu này là trình biên dịch sẽ loại bỏ đi nhu cầu đọc và ghi của một số biến không cần thiết trong chương trình.

Optimization được thực hiện bởi trình biên dịch nhằm tác động đến chương trình với các mục đích:

  • Giảm số lượng các câu lệnh (code space optimization)
  • Giảm thời gian truy cập bộ nhớ (time space optimization)
  • Giảm năng lượng tiêu thụ

Vậy thì làm cách nào để chúng ta có thể sử dụng tính năng tối ưu hóa của trình biên dịch? Hầu hết các trình biên dịch cung cấp cho chúng ta các tùy chọn cho các mức độ tối ưu hóa khác nhau để người lập trình có thể dễ dàng lựa chọn và áp dụng.

Khi các bạn xây dựng chương trình mới, mặc định trình biên dịch sẽ không áp dụng bất cứ tùy chọn optimization nào. Chúng ta cần thiết lập thông qua việc lựa chọn một trong các tùy chọn được cung cấp của trình biên dịch. Optimization giúp chương trình tối ưu hơn nhưng đánh đổi lại với việc thời gian biên dịch lớn hơn, có thể ảnh hưởng đến khả năng debug, thậm chí là hệ thống hoạt động không theo mong muốn hoặc sinh ra bug.

2. Các tùy chọn optimization được gcc hỗ trợ

Trong bài viết này mình sẽ đề cập đến đặc điểm của các tùy chọn optimization thường được sử dụng trong arm-none-eabi-gcc – trình biên dịch GCC cho các vi điều khiển sử dụng vi xử lý ARM, ví dụ như STM32. Mỗi tùy chọn sẽ bao gồm một danh sách các yêu cầu tối ưu cụ thể mà trình tối ưu cần thực hiện. Tùy chọn càng có nhiều yêu cầu tối ưu sẽ giúp trình biên dịch cải thiện hiệu suất hoặc kích thước mã chương trình nhưng đổi lại việc gia tăng thời gian biên dịch và ảnh hưởng khả năng debug chương trình.

Nắm rõ về đặc điểm của các tùy chọn sẽ giúp chúng ta có các lựa chọn phù hợp. Các bạn có thể xem cụ thể chi tiết về từng tùy chọn tại đây.

-O0
  • Mặc định, không áp dụng optimization
  • Có thời gian biên dịch nhanh nhất
  • Đảm bảo cho việc debug được chuẩn xác, thường được áp dụng trong quá trình phát triển sản phẩm
  • Không nên sử dụng nếu tài nguyên phần cứng hạn chế về RAM
-O1
  • Áp dụng optimization
  • Mất nhiều thời gian biên dịch hơn (do phải xử lý các thuật toán tối ưu)
  • Giảm thời gian truy cập bộ nhớ và kích thước mã chương trình một cách vừa phải
-O2
  • Áp dụng optimization cao hơn -O1
  • Thời gian biên dịch chậm
  • Sử dụng hầu hết các optimization mà trình biên dịch cung cấp
  • Có thể không thực hiện được quá trình debug chuẩn xác
-O3
  • Áp dụng optimization -O2 và một số yêu cầu nâng cao khác
  • Có thời gian biên dịch chậm nhất
  • Có thể không thực hiện được quá trình debug chuẩn xác
  • Có khả năng hoạt động không bình thường hoặc sinh ra bug

Ngoài các tùy chọn thường được sử dụng trên, một số tùy chọn khác mà trình biên dịch gcc cung cấp là -Os, -Ofast, -Og, -Oz. Việc lựa chọn một trong các tùy chọn trên cho dự án của bạn rất quan trọng. Đặc biệt đối với những dự án có nguồn tài nguyên phần cứng hạn chế, bạn có thể sẽ cần phải làm việc với nhiều tùy chọn, kết hợp kiểm thử để lựa chọn được tùy chọn phù hợp nhất.

3. Thực hành với các tùy chọn Optimization

3.1. Debug chương trình ví dụ

Mình lấy một ví dụ đơn giản giúp chúng ta thấy được sự khác nhau giữa các cờ biên dịch.

Hàm main sẽ trả về giá trị sum là tổng của các số hạng mang giá trị từ 0 đến 99.

Để phân tích rõ hơn về sự khác nhau khi biên dịch với các flag, các bạn cần nắm 4 giai đoạn trong quá trình dịch

Để có thể phân tích quá trình và kết quả biên dịch tương đồng với Vi điều khiển, mình sẽ tạo project trên STM32CubdeIDE với dòng STM32C8T6. Chúng ta sẽ phân tích chương trình trên dưới dạng kết quả của giai đoạn 2 – giai đoạn Compiler (phân tích cú pháp và chuyển đổi file về ngôn ngữ bậc thấp assembly).

-O0

Bạn có thể thấy rõ được sự triển khai code assembly hoàn toàn giống với source code được đưa ra. Video dưới đây sẽ hướng dẫn chi tiết việc thực thi lệnh, lưu trữ dữ liệu trên thanh ghi (register) và bộ nhớ RAM của chương trình trên với option O0.

Chúng ta tiếp tục phân tích với O1.

-O1

Đơn giản hơn nhiều so với O0, O1 chỉ sử dụng thanh ghi R3 để lưu trữ giá trị của i sau mỗi loop. Còn giá trị của sum đã được tính toán trước và lưu vào R1. Đây là một trong những đặc tính của các cờ tối ưu – Tối ưu hóa các phép tính. Một sự tối ưu lớn về kích thước chương trình và tốc độ thực thi. Nhưng nhìn nhận thì vẫn có sự lãng phí tài nguyên với vòng lặp i chỉ để giảm giá trị của i từ 100 về 0.

Tiếp tục với các cờ có độ tối ưu cao hơn, trong trường hợp này, source code biên dịch của O2 và O3 giống nhau như dưới đây: 

-O2

-O3

Tại flag O2 và O3, vòng lặp for đã được loại bỏ. Trình biên dịch tính toán sẵn giá trị sum (4950) và chuyển giá trị này vào R1.

Qua lần lượt các phân tích trên, sự tối ưu tăng dần từ việc sử dụng cờ O0 đến O3.

3.2. Không gian bộ nhớ và thời gian triển khai chương trình

Khi chúng ta áp dụng các option tối ưu, dung lượng và thời gian biên dịch, thời gian triển khai sẽ có sự khác nhau. Dưới đây là bảng phân tích thời gian biên dịch và dung lượng của các phân vùng bộ nhớ của chương trình trên. Các bạn có thể tìm hiểu thêm về các phân vùng bộ nhớ tại đây.

Bảng 1. Thời gian biên dịch và dung lượng các phân vùng Text, Data, BSS

Text Session (B) Data Session (B) Bss Session (B) Sum (Dec) Sum (Hex) Time compiler (ms)
O0 5080 112 1592 6784 1a80 1668
O1 4804 112 1592 6508 196c 1849
O2 4820 112 1592 6524 197c 1912
O3 4820 112 1592 6524 197c 1918

Một số nhận xét được rút ra từ kết quả trên: 

  • Phân vùng Data lưu trữ các biến toàn cục và biến tĩnh được khai báo với giá trị khác 0 nên dung lượng lưu trữ không thay đổi.
  • Phân vùng Bss lưu trữ các biến được khởi tạo giá trị bằng 0 hoặc không được khởi tạo giá trị rõ ràng nên dung lượng không thay đổi.
  • Phân vùng Text lưu trữ các lệnh chương trình, giá trị hằng, các thuật toán tối ưu, … Một câu hỏi đưa ra là tại sao hàm main của O2 và O3 tối ưu hơn so với O1 nhưng có phân vùng text lại lơn hơn? Một số các yêu cầu của trình biên dịch được sử dụng trong O2 và O3 nhằm làm giảm thời gian triển khai và đánh đổi với việc làm gia tăng kích thước mã chương trình. Cụ thể hơn tại ví dụ trên, ngoài hàm main, chương trình còn bao hàm những hàm khởi tạo cho Vi điều khiển, và đó chính là nguyên nhân làm tăng kích thước phân vùng text với cờ O2, O3.
  • Thời gian biên dịch của các tùy chọn lần lượt tăng dần từ O0 đến O3

Vậy là chúng ta đã cùng tìm hiểu về các cờ tối ưu của arm-none-eabi-gcc. Qua bài viết này, mình mong các bạn hiểu được tầm quan trọng của các tùy chọn trong tối ưu. Phân tích và lựa chọn được tùy chọn phù hợp cho dự án của riêng mình.

Chúc các bạn thành công!

N.T.Nhien

 

Tìm hiểu thêm:
Fanpage Cộng đồng Kỹ thuật TAPIT: TAPIT – AIoT Learning