Australia/Sydney
--:--:--
Posts

Compiling Python 3.11 from Source on a Raspberry Pi: Thermal Limits, OOM Crashes and What Actually Worked

December 1, 2024
DoorPi was a home-made IoT door lock I built in high school. It took voice commands and smartphone requests and drove the lock through a relay on a Raspberry Pi. I don't use it these days, but one part of the project taught me more than the rest of it combined, and that was getting Python 3.11 onto the Pi in the first place. The Pi was running Debian Bullseye on an armv7l kernel, and the package repositories weren't offering 3.11. That meant compiling from source, which turned into a lesson in why compiler flags exist, how heat affects long builds, and why make altinstall is not optional if you care about the rest of the system. Debian Bullseye ships Python 3.9. The speech recognition libraries I was using, along with some of their dependencies, wanted 3.11, partly for the asyncio performance work in that release and partly for compatibility with newer library versions. I had three options.
  1. Pin older library versions that still supported Python 3.9
  2. Pull Python from a third-party source. The usual suggestion online is the deadsnakes PPA, but that is built for Ubuntu rather than Debian, so it wasn't a clean fit and it brings its own trust questions
  3. Compile Python 3.11 directly from the CPython source
Option 3 is the most controlled. You know exactly what you're installing, you choose the build flags, and you own the result. The cost is time and, as it turned out, careful management of the build itself. The Pi I used was an older model with 1 GB of RAM running a 32-bit armv7l kernel. That is fine for running a small Python service and much less fine for a long, CPU-heavy compile. Memory. Building Python is memory-hungry. GCC, the linker and the other build tools all run at once during a parallel build, and 1 GB is workable but tight. Heat. The Pi had no active cooling and ran headless. During the build the CPU climbed past 77 °C, close to the point where the firmware starts throttling the clock. Throttling on its own just slows things down, but the hottest and longest builds were also the ones that ran out of memory and had GCC killed partway through. Time. Even with parallel jobs, a full build on this hardware took several hours.
Bash
wget https://www.python.org/ftp/python/3.11.x/Python-3.11.x.tgz
tar -xzf Python-3.11.x.tgz
cd Python-3.11.x
Substitute the 3.11 point release you want. Before configuring, install the build dependencies.
Bash
sudo apt update
sudo apt install -y build-essential libssl-dev libffi-dev zlib1g-dev \
    libbz2-dev libreadline-dev libsqlite3-dev libncurses5-dev \
    libgdbm-dev libnss3-dev liblzma-dev uuid-dev tk-dev
Missing dependencies won't necessarily fail the build. Python will quietly compile without the modules that need them, and you only find the gaps when an import fails later. Installing everything upfront avoids that. This is where the important decisions happen. The command most guides recommend is
Bash
./configure --enable-optimizations
The flag is spelt the American way because it's an identifier, and getting it wrong means configure won't recognise it. --enable-optimizations turns on Profile Guided Optimisation (PGO). Link-Time Optimisation (LTO) is a separate flag, --with-lto, and a lot of guides pair the two. On a desktop with decent cooling this gives you a noticeably faster interpreter. On a hot, memory-limited Pi it was what kept breaking my build. Why PGO fails here PGO builds Python twice. The first pass produces an instrumented interpreter, which then runs a training workload to collect profiling data (.gcda files under GCC). The second pass rebuilds everything using that data. That roughly doubles the build time, adds the overhead of running the instrumented binary, and raises peak memory use during the rebuild. Put that together on a 1 GB Pi without a fan and you get more heat for longer and more memory pressure at the worst moment. For me the result was either GCC being OOM-killed mid-compile or a build that crawled along under throttling for hours. Adding --with-lto makes the memory problem worse again, because the final link step is very heavy. What worked on constrained hardware
Bash
./configure --prefix=/usr/local --without-ensurepip
Leaving out --enable-optimizations (and --with-lto) skips PGO and LTO entirely. The interpreter is somewhat slower than a fully optimised build, but it compiles reliably and runs correctly on this hardware, which mattered more to me. If you want some optimisation without the PGO overhead
Bash
./configure --prefix=/usr/local --without-ensurepip CFLAGS="-O2"
-O2 turns on the standard compiler optimisations without any profiling pass. Running make with every core at once maximises heat and memory pressure at the same time.
Bash
# Avoid this on constrained hardware
make -j$(nproc)

# Use a limited job count instead
make -j2
Two jobs was a reasonable balance for me. It was faster than a single-threaded build but left enough headroom to avoid the spikes, and -j2 reliably finished builds where -j4 failed partway through. Keep an eye on temperatures while the build runs.
Bash
watch -n 5 cat /sys/class/thermal/thermal_zone0/temp
The value is in millidegrees Celsius, so divide by 1000. Once you're seeing numbers around 80000 (80 °C), the CPU is throttling and the build is at risk. A few things help.
  • Reduce the -j count further
  • Add a heatsink if there isn't one
  • Run the build when the room is cooler
  • Pause the build with Ctrl+Z, let the Pi cool down, then resume with fg
For long unattended builds, a small Bash script that watches the temperature and pauses make above a threshold is worth the effort. This is the most important step. Debian's own tools depend on the system Python 3.9, and replacing it can break apt, system scripts and a range of other utilities. make install sets up the unversioned python3 names under the prefix, which can end up shadowing the system Python on your PATH. make altinstall only installs the versioned python3.11 binary and leaves python3 alone.
Bash
sudo make altinstall
A system with a broken Python is very hard to repair, and on a Pi that usually means re-imaging the SD card and rebuilding everything from scratch. Because I configured with --without-ensurepip, no pip3.11 was installed globally either, which suited me since every package went into a virtual environment anyway. Check the result.
Bash
python3.11 --version
# Python 3.11.x
which python3.11
# /usr/local/bin/python3.11
which python3
# /usr/bin/python3  ← still the system Python 3.9
With 3.11 sitting alongside the system Python, keep project packages in a virtual environment. Creating the venv still bootstraps pip inside it, even with the flag above.
Bash
python3.11 -m venv <venv-path>
source <venv-path>/bin/activate
pip install SpeechRecognition RPi.GPIO pigpio
This keeps the project's dependencies away from both the system Python and anything else on the Pi, so conflicts between projects stop being a problem. The lock ran headless, and the speech recognition and GPIO scripts needed to keep running after my SSH session dropped. tmux handled that.
Bash
tmux new-session -d -s <session-name> 'source <venv-path>/bin/activate && python3.11 <path-to-script>'
That starts a detached session that survives SSH disconnecting, and you can reattach later to debug.
Bash
tmux attach -t <session-name>
For starting on boot, a cron @reboot entry did the job.
Bash
crontab -e
# Add
@reboot sleep 10 && tmux new-session -d -s <session-name> 'source <venv-path>/bin/activate && python3.11 <path-to-script>'
The sleep 10 gives the system time to finish booting. Without it the session could start before the GPIO interface was ready. Use a Pi with more RAM. A board with 2 GB or 4 GB would have handled this build far more comfortably. The 1 GB limit caused most of the pain. Cross-compile on a faster machine. Building for armv7l on a modern x86 machine takes minutes instead of hours. The compiled Python can then be copied to the Pi and installed. Set aside a full day. The first build, including false starts and waiting for the Pi to cool down, took most of a day. Once the right flags were written down, later builds were quicker, but still not quick. I ended up with a working Python 3.11 on Debian Bullseye that sat safely beside the system Python, with the project's dependencies in their own virtual environment and the service running under tmux. The lock itself has since been retired, but the build is the part I still think about. Working out why PGO falls over on constrained hardware, with the second build pass, the instrumented training run and the memory and heat that come with them, is the kind of thing you only really learn by watching the build fail and tracking down why. The Python documentation and the GCC manual are thorough. What's less documented is how all of these constraints interact on small embedded boards, and hopefully this fills part of that gap.
On this page