|
| 1 | +# Trying out free-threaded Python on macOS |
| 2 | + |
| 3 | +Inspired by [py-free-threading.github.io](https://py-free-threading.github.io/) I decided to try out a beta of Python 3.13 with the new free-threaded mode enabled, which removes the GIL. |
| 4 | + |
| 5 | +## Installation |
| 6 | + |
| 7 | +I chose to use the macOS installer to get a pre-built binary. I downloaded and ran the macOS installer from [www.python.org/downloads/release/python-3130b3/](https://www.python.org/downloads/release/python-3130b3/), and then when I got to this screen: |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | +I selected the "Customize" option and checked this additional box: |
| 12 | + |
| 13 | + |
| 14 | + |
| 15 | +Once it had finished installing I didn't run the script to add it to my path (I didn't want to intefere with my many other Python versions). |
| 16 | + |
| 17 | +This gave me two new Python binaries - one with free-threading enabled and one without: |
| 18 | + |
| 19 | +- `/Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13` |
| 20 | +- `/Library/Frameworks/PythonT.framework/Versions/3.13/bin/python3.13t` |
| 21 | + |
| 22 | +Note the `PythonT.framework` in the second path. |
| 23 | + |
| 24 | +It also seemed to setup the following aliases in `/usr/local/bin`: |
| 25 | + |
| 26 | +- `/usr/local/bin/python3.13` |
| 27 | +- `/usr/local/bin/python3.13t` |
| 28 | + |
| 29 | +These are symlinks: |
| 30 | +```bash |
| 31 | +ls -lah /usr/local/bin | grep python3.13 |
| 32 | +``` |
| 33 | +``` |
| 34 | +lrwxr-xr-x 1 root wheel 73B Jul 12 16:26 python3.13 -> ../../../Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 |
| 35 | +lrwxr-xr-x 1 root wheel 80B Jul 12 16:26 python3.13-config -> ../../../Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13-config |
| 36 | +lrwxr-xr-x 1 root wheel 81B Jul 12 16:26 python3.13-intel64 -> ../../../Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13-intel64 |
| 37 | +lrwxr-xr-x 1 root wheel 75B Jul 12 16:26 python3.13t -> ../../../Library/Frameworks/PythonT.framework/Versions/3.13/bin/python3.13t |
| 38 | +lrwxr-xr-x 1 root wheel 82B Jul 12 16:26 python3.13t-config -> ../../../Library/Frameworks/PythonT.framework/Versions/3.13/bin/python3.13t-config |
| 39 | +``` |
| 40 | + |
| 41 | +Starting those Python processes shows which one has free-threading in the interpreter header: |
| 42 | +``` |
| 43 | +% /usr/local/bin/python3.13 |
| 44 | +Python 3.13.0b3 (v3.13.0b3:7b413952e8, Jun 27 2024, 09:57:31) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin |
| 45 | +Type "help", "copyright", "credits" or "license" for more information. |
| 46 | +>>> |
| 47 | +% /usr/local/bin/python3.13t |
| 48 | +Python 3.13.0b3 experimental free-threading build (v3.13.0b3:7b413952e8, Jun 27 2024, 10:04:51) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin |
| 49 | +Type "help", "copyright", "credits" or "license" for more information. |
| 50 | +>>> |
| 51 | +``` |
| 52 | + |
| 53 | +## Testing it out |
| 54 | + |
| 55 | +I asked Claude 3.5 Sonnet to write me [a quick test script](https://claude.site/artifacts/92aec50b-43c7-463a-b8a1-5d5ea0600708), then iterated on and simplified the result myself until I got to this: |
| 56 | + |
| 57 | +```python |
| 58 | +import argparse |
| 59 | +import time |
| 60 | +from concurrent.futures import ThreadPoolExecutor |
| 61 | + |
| 62 | + |
| 63 | +def cpu_bound_task(n): |
| 64 | + """A CPU-bound task that computes the sum of squares up to n.""" |
| 65 | + return sum(i * i for i in range(n)) |
| 66 | + |
| 67 | + |
| 68 | +def main(): |
| 69 | + parser = argparse.ArgumentParser(description="Run a CPU-bound task with threads") |
| 70 | + parser.add_argument("--threads", type=int, default=4, help="Number of threads") |
| 71 | + parser.add_argument("--tasks", type=int, default=10, help="Number of tasks") |
| 72 | + parser.add_argument( |
| 73 | + "--size", type=int, default=5000000, help="Task size (n for sum of squares)" |
| 74 | + ) |
| 75 | + args = parser.parse_args() |
| 76 | + |
| 77 | + print(f"Running {args.tasks} tasks of size {args.size} with {args.threads} threads") |
| 78 | + |
| 79 | + start_time = time.time() |
| 80 | + with ThreadPoolExecutor(max_workers=args.threads) as executor: |
| 81 | + list(executor.map(cpu_bound_task, [args.tasks] * args.size)) |
| 82 | + end_time = time.time() |
| 83 | + duration = end_time - start_time |
| 84 | + |
| 85 | + print(f"Time with threads: {duration:.2f} seconds") |
| 86 | + |
| 87 | + |
| 88 | +if __name__ == "__main__": |
| 89 | + main() |
| 90 | +``` |
| 91 | +I saved this as `gildemo.py` and tried running it with the new Python binaries the one with free-threading enabled and the one without. |
| 92 | + |
| 93 | +Here's what I saw in Activity Monitor while running the scripts: |
| 94 | + |
| 95 | +No free-threading (with the GIL) - reported 99% CPU usage: |
| 96 | + |
| 97 | + |
| 98 | + |
| 99 | +Free-threading (no GIL) - reported 258.8% CPU usage: |
| 100 | + |
| 101 | + |
| 102 | + |
| 103 | +And here are some results from running the script. First with the GIL in place: |
| 104 | +``` |
| 105 | +% python3.13 gildemo.py --size 1000000 |
| 106 | +Running 10 tasks of size 1000000 with 4 threads |
| 107 | +Time with threads: 17.14 seconds |
| 108 | +``` |
| 109 | +And then without the GIL: |
| 110 | +``` |
| 111 | +% python3.13t gildemo.py --size 1000000 |
| 112 | +Running 10 tasks of size 1000000 with 4 threads |
| 113 | +Time with threads: 8.19 seconds |
| 114 | +``` |
0 commit comments