MicroPython _thread exercise
The purpose of this exercise is to test the behavior of MicroPython _thread, specifically for Raspberry Pi Pico series, whether _thread.start_new_thread() can provide real multi-core parallel speedup for pure Python CPU-bound work.
mpy_thread_smp_bench.py# mpy_thread_smp_bench.py
#
# Purpose:
# Simple MicroPython benchmark to test whether _thread.start_new_thread()
# can provide real multi-core parallel speedup for pure Python CPU-bound work.
#
# Method:
# 1. Run a CPU-bound loop in the main thread and measure the duration.
# This is the single-thread baseline.
#
# 2. Run the same CPU-bound loop in two threads:
# - one in the main thread
# - one in a newly created _thread
#
# 3. Measure:
# - duration of thread 0
# - duration of thread 1
# - total wall-clock time from starting both threads until both finish
#
# Interpretation:
# Let single-thread duration be T.
#
# If the two CPU jobs run fully in parallel on two cores:
# total wall time ~= T
# speedup indicator ~= 200
#
# If the two jobs are serialized, for example because:
# - the chip has only one core
# - the OS/scheduler uses only one core
# - MicroPython uses a GIL / global VM lock
# - there is severe shared-resource contention
# then:
# total wall time ~= 2*T
# speedup indicator ~= 100
#
# Notes:
# - Choose N so that the single-thread run takes about 100 ms to 500 ms.
# If N is too small, thread startup, printing, interrupts and scheduler
# overhead will dominate the result.
#
# - This benchmark intentionally avoids printing during the timed loop.
# Results are stored and printed only after the measurement is complete.
#
# - The lock is only used to protect the result flags/variables after the
# timed work is finished. It is not part of the measured CPU loop.
#
# - Different MicroPython ports behave differently. On some ports, threads
# may be true parallel workers on separate cores. On others, Python
# bytecode execution may be serialized even on dual-core hardware.
import os
import sys
import _thread
import time
# Adjust this value so that the single-thread run takes about 100-500 ms.
# If it is too fast, increase N. If it is too slow, decrease N.
N = 50000
lock = _thread.allocate_lock()
res = [0, 0]
done = [False, False]
def burn(n):
"""
Simple CPU-bound loop.
It does not intentionally allocate large objects or perform I/O.
"""
x = 0
for i in range(n):
x += 1
return x
def worker(idx):
"""
Worker function executed by each thread.
Measures only the CPU-bound loop, then stores the result.
"""
t0 = time.ticks_us()
burn(N)
dt = time.ticks_diff(time.ticks_us(), t0)
lock.acquire()
res[idx] = dt
done[idx] = True
lock.release()
print("=========================================================")
print(sys.implementation[0], os.uname()[3],
"\nrun on", os.uname()[4])
print("=========================================================")
# ---------- Single-thread baseline ----------
t0 = time.ticks_us()
burn(N)
single = time.ticks_diff(time.ticks_us(), t0)
print("single thread duration =", single, "us")
# ---------- Two-thread test ----------
done[0] = False
done[1] = False
t0 = time.ticks_us()
_thread.start_new_thread(worker, (1,))
worker(0)
# Wait until both workers have finished.
while True:
lock.acquire()
all_done = done[0] and done[1]
lock.release()
if all_done:
break
time.sleep_ms(1)
wall = time.ticks_diff(time.ticks_us(), t0)
print("thread 0 duration =", res[0], "us")
print("thread 1 duration =", res[1], "us")
print("total wall time =", wall, "us")
# Speedup indicator:
# 100 means no throughput gain: wall ~= 2*single
# 200 means ideal dual-core gain: wall ~= single
print("speedup indicator (100 = serial/single, 200 = ideal dual):",
(200 * single) // wall)
Comments
Post a Comment