Skip to content

Free-threaded Python: 3.14t/3.15t wheels and a lock-free word cache - #39

Merged
vthorsteinsson merged 8 commits into
mainfrom
free-threading
Oct 15, 2026
Merged

vthorsteinsson merged 8 commits into
mainfrom
free-threading

Conversation

@HaukurPall

Copy link
Copy Markdown
Member

What is the problem?

islenska didn't build on free-threaded Python (3.14t). Its word cache also held one lock across every lookup, so more threads made lookups slower.

What is the solution?

  • Free-threaded CPython gets a regular extension, since it has no abi3. cffi 2.x keeps the GIL off.
    • CI builds cp314t and cp315t wheels.
    • CI tests 3.14t and 3.15t, also with every test in 8 threads at once.
  • GenerationalCache replaces LFU_Cache, which nothing outside islenska used.
    • One cache shared by all threads, read without a lock. On real text it hits as often as the LFU cache did.
    • Cached words keep their finished BinEntry tuples, so a hit only copies a list. Constructing namedtuples doesn't scale on 3.14t.
  • The mmap buffers are released explicitly. This fixes a BufferError at shutdown on free-threaded builds.
  • libbin is built with -O2 when the interpreter records no optimization level. Gentoo's Python records none, so libbin ran at -O0, 4× slower.

How was this tested?

  • Results unchanged: every public lookup method on 158k word forms from ASR spec labels, for Bin and GreynirBin, gives the same checksums as main.
  • Suite: passes on 3.14.7t and 3.15t, both serially and in 8 threads with pytest-run-parallel, and on GIL 3.14.
    • New test_threads.py: it fails within a second if the old cache's lock is removed.
    • New test_cache.py: cached results equal uncached ones.
  • Speed: Bin.lookup on real text, in thousands of lookups/s:
    • GIL build, 1 thread: 204 → 313.
    • 3.14t, 8 threads: 155 → 1008. For comparison, 8 processes on the GIL build reach 890.
  • Not yet run: wheels.yml, which only runs on tags. It needs a dry run with a test tag such as 2.0.1-test1.

🤖 Generated with Claude Code

HaukurPall and others added 5 commits October 15, 2026 16:29
The free-threaded build doesn't support the limited API, so it gets a
regular (non-abi3) extension. cffi 2.x marks the module as free-threading
safe, so the GIL stays off.

Release the cffi buffers of the memory maps explicitly before closing them.
The cyclic GC of free-threaded builds keeps objects alive while finalizers
run, so at shutdown Dawg.__del__ could not close its mmap (BufferError).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test_threads.py runs lookups from 8 threads with small caches, so entries
are also evicted under contention, and compares them with serial results.
Without the LFU cache lock it fails on 3.14t within a second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cibuildwheel builds cp314t and cp315t wheels next to the abi3 wheel; the
smoke test checks that importing the extension keeps the GIL off. The test
matrix runs 3.14t and 3.15t, and on those also runs every test in eight
threads at once with pytest-run-parallel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gentoo's Python records no -O flag for extension builds (its ebuild passes
the user's CFLAGS as CFLAGS_NODIST), so a source build of libbin outside
Portage ran at -O0, about four times slower per lookup. Add -O2 unless the
interpreter's flags or CFLAGS/CXXFLAGS already set a level, so builds that
use -O3 keep it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LFU_Cache held one lock across every lookup, including the computation on
a miss, so on free-threaded Python adding threads made lookups slower.
GenerationalCache keeps a new and an old generation of word forms in two
dicts: reads take no lock, and a race can at worst compute a word twice.
With generations of 4096 it matches the LFU cache's hit rate on real text.

Each cached word form also keeps its finished BinEntry tuples, made on
first use, so a lookup() hit copies a list instead of building namedtuples,
which do not scale across threads on 3.14t. Plain lookup() of real text:
about 1.5x faster on one thread, 6.5x faster with eight threads on 3.14t.

LFU_Cache is gone; nothing outside islenska used it, and its hits/misses
counters went with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
vthorsteinsson and others added 3 commits October 15, 2026 17:17
cffi 2.0 is the first version that runs free-threaded CPython without the
GIL. Add the Free Threading classifier, and list the cp314t and cp315t
wheels in RELEASING.md (30 wheels in all) and the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cibuildwheel fails only if a build selector matches nothing, so a missing
cp315t (or any other) wheel would otherwise pass unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Threads creating their first instances at once could open BÍN twice or
read BinErrata.conf twice. Also note the cache's memory per word form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vthorsteinsson
vthorsteinsson merged commit d973457 into main Oct 15, 2026
20 checks passed
@HaukurPall
HaukurPall deleted the free-threading branch October 15, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants