Repository navigation
Free-threaded Python: four Granian worker threads use one CPU core while four processes scale normally #906
Replies: 2 comments
|
Four threads pinned to roughly one core while four processes scale to 3.3 is the signature of the GIL being on. Before digging into Granian internals I would confirm it is actually still off at the point where your request handlers run. The catch is where you measured it. A free-threaded build starts with the GIL disabled, but any C extension that has not declared Add this inside an actual route rather than at startup: import sys
@app.get("/gil")
async def gil():
return {"gil_enabled": sys._is_gil_enabled()}If that returns The likely candidate in your stack is This also fits the rest of your results. Going from SQLAlchemy 2.0.52 to 2.1.0b3 changing nothing is consistent with the driver being the thing that flips the GIL rather than the ORM. And the fact that four processes still scale fine is expected, since each process gets its own interpreter and its own GIL, so a re-enabled GIL costs you nothing in the process model and costs you everything in the thread model. Two ways to confirm:
There is a closely related thread on the SQLAlchemy side at sqlalchemy/sqlalchemy#13256 covering the same "3.14t still re-enables the GIL" behaviour with this driver stack, which is worth reading. If the in-request check comes back |
|
On question 1, the topology is expected and it's decided more bluntly than you'd probably guess. from .._granian import BUILD_GIL
from .mp import MPServer as MPServer
from .mt import MTServer as MTServer
Server = MPServer if BUILD_GIL else MTServerand #[cfg(not(Py_GIL_DISABLED))]
const BUILD_GIL: bool = true;
#[cfg(Py_GIL_DISABLED)]
const BUILD_GIL: bool = false;So the choice is made from how the interpreter was built, at import time, and there is no runtime consultation of That matters for question 4, because it's the mechanism that turns a re-enabled GIL into the worst case rather than a wash. A C extension that hasn't declared Granian itself is clear of suspicion on this, for what it's worth — the extension declares The in-route import sys, importlib
for name in [
"granian", "psycopg", "psycopg_c", "psycopg_binary",
"pydantic_core", "sqlalchemy", "fastapi",
]:
before = sys._is_gil_enabled()
try:
importlib.import_module(name)
except ImportError:
print(f"{name:16} (not installed)")
continue
after = sys._is_gil_enabled()
print(f"{name:16} {before} -> {after}{' <-- culprit' if after and not before else ''}")Run that as the container's entrypoint in a fresh process. The first line that transitions If the in-route check comes back Either way, there's a reasonable feature request in here for Granian: |
Uh oh!
There was an error while loading. Please reload this page.
Hi, I am investigating how Granian behaves with CPython 3.14 free-threading and an async FastAPI/SQLAlchemy application.
The short version:
sys._is_gil_enabled()reportsFalse.2.0.52to2.1.0b3did not improve the result.I am trying to understand whether this is expected behavior for Granian’s free-threaded worker implementation, or whether there is another serialization point in the server/runtime path.
Environment
29.4.03.243.14.72.8.10.141.13.3.43.3.418-alpine2.0.52for the original comparison2.1.0b3for the follow-up comparisonThe application is a small FastAPI service using async SQLAlchemy with the psycopg driver. Database engines and pools are cached per event loop because a single SQLAlchemy
AsyncEnginecannot safely be shared across the separate Granian event loops.Granian configuration
The container command is:
The worker count is configured through:
I did not configure additional Granian runtime threads or a separate runtime mode.
For the free-threaded run, the image was built with CPython
3.14t.The runtime probe reported:
Granian startup also reported:
So this is one OS process with four Granian worker threads and four event loops.
For comparison, standard CPython
3.14withGRANIAN_WORKERS=4reported four separate worker PIDs:Workload
The benchmark repeatedly sends:
The endpoint executes approximately:
The load test uses:
LOGFIRE_TOKEN= LOGFIRE_SEND=false \ uv run scripts/loadtest.py \ --path '/products?limit=50' \ --concurrency 128 \ --duration 20Logfire export was explicitly disabled for all relevant runs.
The SQL query itself is very cheap:
The test database contains only a small number of products.
Results
Four free-threaded workers, SQLAlchemy 2.0.52
Container sample during load:
Idle API memory was approximately:
Four standard-GIL worker processes, SQLAlchemy 2.0.52
Container sample during load:
Idle API memory was approximately:
The four-process configuration therefore used substantially more memory, but it also used approximately 3.3 CPU cores and achieved about 5.3x the throughput.
Four free-threaded workers, SQLAlchemy 2.1.0b3
After rebuilding the image from scratch with SQLAlchemy
2.1.0b3, the runtime still reported:Two runs produced:
The API remained around one CPU core during the benchmark, and PostgreSQL remained mostly idle:
The SQLAlchemy 2.1 runs were not faster than the SQLAlchemy 2.0.52 run. They were somewhat slower in this particular test, although I have not treated that as a reliable SQLAlchemy regression because this is a prerelease build and the benchmark is not yet statistically rigorous.
PostgreSQL observations
The database does not appear to be the bottleneck in the free-threaded configuration.
During the four-worker free-threaded test:
The application uses:
Because there is one pool per event loop, four workers can create up to:
This explains the relatively high idle connection count, but PostgreSQL was not close to its configured connection limit.
There was one transient sample showing several
idle in transactionsessions, but a follow-up query found zero such sessions after the load ended. I do not currently believe this indicates leaked transactions.Questions
Reproduction
I can push up my branch with all the code, lmk if that would be helpful to reproduce
All reactions