Threads¶
A native function releases the GIL, so compute in it scales across threads.
busy is a 200-million-iteration loop. Under plain CPython two threads take
as long as one; under ppy run they finish in half the time. It is the same
file and the same threading.Thread.
Run it¶
What it prints¶
python threads.ppy
ppy run threads.ppy
The scaling line at the bottom of the output is the measurement, 1.95× here.
The kernel¶
@ppy.pure
@ppy.opt(3)
def busy(rounds: int) -> int:
total: int = 0
for i in range(rounds):
total += i % 7
return total
Once its arguments are unpacked, busy touches no Python object. The
generated wrapper therefore wraps the call in Py_BEGIN_ALLOW_THREADS.
When the GIL stays held¶
- A function with an effect that can reach the interpreter keeps the GIL.
- A function that performs I/O is not lowered at all.
- Borrowed buffers get the same treatment NumPy gives them: the boundary pins the memory for the whole call.
Threads inside native code¶
This example is Python threads calling native code. The other direction,
threads and shared memory inside native code, is
ppy.concurrent and ppy.atomic.
Read on: Atomics and threads · Architecture: the boundary
threads.ppy is hand-written; there is no .py source and no conversion step.
28_threads/threads.ppy¶
import threading
import time
import ppy
@ppy.pure
@ppy.opt(3)
def busy(rounds: int) -> int:
total: int = 0
for i in range(rounds):
total += i % 7
return total
def timed(rounds: int, workers: int) -> float:
threads: list[threading.Thread] = []
for _ in range(workers):
threads.append(threading.Thread(target=busy, args=(rounds,)))
started: float = time.perf_counter()
for thread in threads:
thread.start()
for thread in threads:
thread.join()
return (time.perf_counter() - started) * 1000.0
def main() -> None:
rounds: int = 200000000
one: float = timed(rounds, 1)
two: float = timed(rounds, 2)
print(f"1 thread {one:8.1f} ms")
print(f"2 threads {two:8.1f} ms")
print(f"scaling {2.0 * one / two:8.2f}x")
main()
Source: examples/28_threads.