Faster Python modules: template, codegen, query builder, Dang runtime - #23
Open
eunomie wants to merge 6 commits into
Open
Faster Python modules: template, codegen, query builder, Dang runtime#23eunomie wants to merge 6 commits into
eunomie wants to merge 6 commits into
Conversation
Ideation doc: measured anatomy of a Python module call on the builtin runtime (three ~1.05s boots, ~850ms of each inside Python), in-engine A/B of client-library cuts, an engine-change-free warm-worker feasibility spike, and a ranked pick list across templates, runtime, codegen and client library. Design-only; nothing here is implemented. Signed-off-by: Yves Brissaud <yves@dagger.io>
A Workspace is session state, so a constructor that takes one can never be a cache hit across CLI invocations: the engine re-executed the default template's create() — a full Python runtime boot, ~1s — on every call, even with identical arguments. Measured on v1.0.0-beta.10: 4.25–4.50s per warm call with the Workspace constructor, 3.36–3.71s with a Workspace-free one, where the constructor is CACHED after the first call. The function that reads the workspace now takes it as an argument; the constructor keeps only the plain base_image_address knob. Signed-off-by: Yves Brissaud <yves@dagger.io>
The generator itself takes ~0.2s for the full core schema; the rest of a module's generate was container work: uv run --isolated built a throwaway environment per module, and the alpine (musl) image walks the same Python imports ~35% slower than glibc. The generator's environment is now synced once as a layer keyed on its sources and lock, shared by every module that generates, and the generator and the pyproject strip run straight from that venv. Same image family, glibc variant. The SDK test check moves to the same image. Signed-off-by: Yves Brissaud <yves@dagger.io>
Every boot of a module runtime paid ~140ms for beartype to compile wrappers around the 851 generated client methods, before the call had touched any of them — 40% of the library's import time, and the largest single item in it. The code generator knows every parameter's type, so it now emits the check itself: one isinstance guard per argument, raising the same TypeError with the same message shape. Nothing is decorated at import; the class bodies of the 16k-line client execute in ~5ms. The three remaining beartype.door uses in the module dispatcher and query builder become typing.get_origin/get_args, and beartype leaves the vendored library's dependencies. Measured in-engine on v1.0.0-beta.10 by editing a module's committed sdk/: import 665–684ms → 516–542ms per boot. Signed-off-by: Yves Brissaud <yves@dagger.io>
The generated client already knows every type, field and argument it was generated from, yet the session fetched the schema back from the engine on every boot (a full introspection query) and rebuilt a GraphQLSchema in pure Python so that gql's DSL could construct queries against it. That cost ~60ms of connect and ~110ms of imports (gql, graphql-core, 155 modules) per boot, for a document the query builder can write itself. The query builder now renders selections and argument literals directly and sends the text over an httpx client. Enums go by name, input objects under their GraphQL field names (the generator records the ones that cannot be derived from the Python names), IDs are still resolved implicitly. Errors are mapped from the response body to the same QueryError/ExecError types; debug_query() points at the document actually sent. Connect and execute retries keep their Retry knobs, backed by httpx transport retries and a short backoff. Together with the previous patch, a module boot imports 560 modules instead of ~1,030, with no gql, graphql-core or beartype among them. Signed-off-by: Yves Brissaud <yves@dagger.io>
…able The runtime was a Go Dagger module: every module load paid a container exec of the compiled Go binary plus its discovery round trips — a 0.3s PythonSdkRuntime.moduleRuntime span on the critical path of every call — before the runtime container itself was even built. As Dang it evaluates in-engine, like the Java SDK's runtime, and that exec is gone. Behaviour follows the Go implementation: the same base and uv images from the committed Dockerfiles, .python-version / requires-python / [tool.dagger] overrides, uv index settings, the uv.lock and requirements.lock install paths, the pip fallback, the missing-generated-files error, and the strcase.ToCamel derivation of the main object name — pinned by an e2e check against the Go behaviour for the names that differ between conversions. Two deliberate changes on the way: - The vendored client library is installed non-editable (uv sync --no-editable --no-install-project, uv pip install --no-editable), then the module's own package is installed editable on its own, as before. The library is generated code that is never edited in place, and an editable install put its bytecode in a throwaway mount, recompiled on every function call (~100ms). Steady-state import of the library inside the container drops from 390–440ms to 188ms. This needs uv >= 0.11 for the pip-compatible path, so the pinned uv image moves to 0.11.16. - uv pip check is gone: a separate exec per install for a consistency check the resolver already performs. Measured in-engine on v1.0.0-beta.11, same instrumentation as before this series: per-boot time inside Python 820–850ms → 590–620ms, function spans 1.0–1.1s → 0.8s. Signed-off-by: Yves Brissaud <yves@dagger.io>
eunomie
force-pushed
the
python-perf-ideation-lead-27f8f8da
branch
from
August 31, 2026 06:12
210d7da to
527f77a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Six changes that cut a warm
dagger callon a Python module from ~4.4s to ~2.65s and a cold first call on a fresh engine from ~21s to ~9.5s (measured on v1.0.0-beta.11; details and method inhack/designs/2026-08-25-python-module-performance-ideas.md, which this PR adds).Commits, bottom to top:
hack/designs: the measured ideas document (design record; includes the before/after and cold-call numbers).templates: the default template takes the Workspace incontainer(self, ws)instead of the constructor. A Workspace-arg constructor can never be a cache hit across CLI invocations, so it cost a full runtime boot (~1s) on every call.python-sdk: the code generator runs from a synced venv layer onuv:python3.14-bookworm-sliminstead ofuv run --isolatedon alpine (the generator itself takes 0.2s; the container work was the cost).sdk: the generator emits oneisinstanceguard per method argument and a__post_init__per input object instead of decorating everything with beartype, which compiled wrappers for 851 methods on every process start (~140ms). beartype leaves the vendored library.sdk: queries are rendered as text and sent over httpx; gql and graphql-core leave the runtime. The old session re-fetched the schema by introspection on every boot to drive gql's DSL, for a schema the generated client was generated from.runtime: the module runtime is rewritten in Dang (was a Go module: a 0.3s exec of the runtime binary on every call); the vendored SDK is installed non-editable so its bytecode persists (the module's own package stays editable);uv pip checkdropped; uv pin 0.10.3 → 0.11.16.Compatibility (verified)
Legacy
dagger.jsonmodules are untouched (engine builtin);dagger-module.tomlmodules with an already-vendored older library load fine on the new runtime; modules regenerated with the new library work on the engine builtin; new library + Dang runtime is covered by the e2eruntime-call-check.targetRuntimeis still"python"; modules reach the Dang runtime only by an explicit[runtime] sourceref, as before.Behaviour notes
dagger.Retry.connect/executeare nowbool(the decorator form only existed through gql; it was unreachable from module code). Generated input objects validate their fields at construction, as before.Not in this PR
The same non-editable-install change for dagger/dagger's builtin
sdk/python/runtime.Test
dagger check48/48 against a v1.0.0-beta.11 engine;
cd sdk && uv run pytest206 passed; ruff clean.