Skip to content

Faster Python modules: template, codegen, query builder, Dang runtime - #23

Open
eunomie wants to merge 6 commits into
dagger:mainfrom
eunomie:python-perf-ideation-lead-27f8f8da
Open

Faster Python modules: template, codegen, query builder, Dang runtime#23
eunomie wants to merge 6 commits into
dagger:mainfrom
eunomie:python-perf-ideation-lead-27f8f8da

Conversation

@eunomie

@eunomie eunomie commented Aug 30, 2026

Copy link
Copy Markdown
Member

Six changes that cut a warm dagger call on 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 in hack/designs/2026-08-25-python-module-performance-ideas.md, which this PR adds).

Commits, bottom to top:

  1. hack/designs: the measured ideas document (design record; includes the before/after and cold-call numbers).
  2. templates: the default template takes the Workspace in container(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.
  3. python-sdk: the code generator runs from a synced venv layer on uv:python3.14-bookworm-slim instead of uv run --isolated on alpine (the generator itself takes 0.2s; the container work was the cost).
  4. sdk: the generator emits one isinstance guard 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.
  5. 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.
  6. 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 check dropped; uv pin 0.10.3 → 0.11.16.

Compatibility (verified)

Legacy dagger.json modules are untouched (engine builtin); dagger-module.toml modules 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 e2e runtime-call-check. targetRuntime is still "python"; modules reach the Dang runtime only by an explicit [runtime] source ref, as before.

Behaviour notes

dagger.Retry.connect/execute are now bool (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 check

48/48 against a v1.0.0-beta.11 engine; cd sdk && uv run pytest 206 passed; ruff clean.

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
eunomie force-pushed the python-perf-ideation-lead-27f8f8da branch from 210d7da to 527f77a Compare August 31, 2026 06:12
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.

1 participant