Skip to content

perf: render init templates in the engine, and fix two module names that could not load - #16

Draft
eunomie wants to merge 11 commits into
dagger:mainfrom
eunomie:python-sdk-perf-lead-2c9c8dc8
Draft

perf: render init templates in the engine, and fix two module names that could not load#16
eunomie wants to merge 11 commits into
dagger:mainfrom
eunomie:python-sdk-perf-lead-2c9c8dc8

Conversation

@eunomie

@eunomie eunomie commented Aug 14, 2026

Copy link
Copy Markdown
Member

Trace-driven pass over the three surfaces this module owns: dagger module init python, dagger generate, and what the SDK contributes to dagger call. Baseline, method, raw runs and the ideas that were measured and rejected are all in hack/designs/2026-08-14-python-sdk-performance.md.

What changed

Init renders templates in the engine instead of a Go toolchain. renderedTemplate pulled golang:1.25-alpine, compiled helpers/render-template from source, and ran it — to substitute three variables across a handful of small text files. It now walks the template with glob, expands {{ .Var }} in paths and contents, and carries non-.tmpl files over with withFile. helpers/render-template/ is deleted.

One Go base image instead of two. The helpers pinned golang:1.25-alpine while the polyfill dependency's Go runtime pulls golang:1.26-alpine, so a cold engine pulled both. The remaining helper (helpers/pyproject, used by config get/set and flag-bearing init) now builds on the image the workspace already has.

Two latent bugs fixed, both found while measuring:

  • dagger module init python app --python-version 3.13 produced a module that could not be loaded at allModuleLoadError: name 'App' is not defined. The default template's create() returns the class it is declared in, and deferred annotations only became the default in 3.14 (PEP 649). Fixed with from __future__ import annotations; verified by initializing, generating and calling at 3.12, 3.13 and 3.14.
  • dagger module init python s3-bucket — any name containing a digit — was also broken on main. strcase.ToSnake renders src/s_3_bucket/, which is not what uv_build derives from name = "s3-bucket", so the module failed to load with call constructor: exit code: 1. The new renderer produces src/s3_bucket/ and the module loads and calls.

Honest numbers

Local engine v1.0.0-beta.9; the host is shared and noisy, so arms were interleaved and every run is recorded in the doc.

before after
cold-engine withInitModule 12.4 / 12.0 / 12.7 s 11.2 / 11.3 / 11.1 s
warm-engine withInitModule 1.8 / 1.8 / 2.0 / 2.0 s 1.9 / 1.8 / 1.8 / 1.8 s

So: ~1.2s (≈10%) off a cold init, and nothing measurable when warm — the go build layer was already cached in the warm case. The saving is smaller than the removed work suggests because the helper's build overlapped the polyfill Go build that remains. Expect little or no CI wall-clock change; an earlier draft of the doc predicted a large CI win from check durations and that reasoning was wrong, which the doc now says.

The durable wins are as much qualitative as numeric: the default scaffolding path no longer needs a Go toolchain or a Docker Hub round-trip for a Go image, and one of two helper binaries is gone.

Rendered output was diffed against the deleted Go helper's for my-module, my_module, myModule, HTTPServer and simple across the default, empty and legacy templates — 15 combinations, byte-identical. Digit-bearing names deliberately differ, because strcase's answer was the broken one.

Measured and deliberately not shipped

  • Pinning the polyfill dependency by commit to remove a per-call git ls-remote. It does not work: NewGitRepository calls backend.Remote() unconditionally, and the ref already carries the SHA via refPin. The fix belongs in the engine; dropped rather than shipped as a no-op.
  • use-uv = false measures −12% per call, reproduced across two independent benchmark batches. Not adopted: every in-container micro-benchmark is identical between the arms, so the ~200ms/process lives somewhere nobody could isolate, and it trades away uv's install speed on a path that was not benchmarked. Recorded for the runtime owners instead.
  • base-image = "python:3.14-alpine" is worth knowing about in the other direction: +24% per call, because musl CPython walks the same import chain in 607ms instead of 453ms.

Where the call-path time actually goes

A warm dagger call is ~3.4s, of which ~2.1s is two exec.processRun spans in the Python runtime container. Instrumenting inside that container splits one ~1050ms process as: ~450ms import dagger (300ms of it the generated client/gen.py), 253ms first-connect handshake, ~200ms container setup/teardown, ~60ms recompiling the vendored SDK to bytecode that is then thrown away, 38ms telemetry init — and ~8ms of actual interpreter start, 0.8% of the total.

None of that is fixable here; it lives in dagger/dagger's sdk/python/runtime. The three cost centres are written up in the doc for whoever picks them up upstream.

Testing

All 35 workspace checks pass locally (13 e2e, 22 sdk-sdk). The e2e additions were written to protect this rewrite specifically: exact-file-set assertions (which caught a stray file during development), a naming matrix including the digit and HTTPServer cases, an assertion that pyproject.toml is actually expanded — .ModuleName appears nowhere else, so a regression there previously passed every check — and an assertion that the __future__ import stays in the template.

The doc records what was accepted and not fixed: template symlinks are no longer rejected, empty template directories are dropped, and the template language is narrowed from Go text/template to {{ .Var }} substitution only.

eunomie added 11 commits August 14, 2026 17:11
Records a measured baseline for the three surfaces this module owns (init,
generate, and its contribution to the call path), captured from local engine
traces rather than intuition, plus the improvements that follow from it.

Signed-off-by: Yves Brissaud <yves@dagger.io>
renderedTemplate pulled golang:1.25-alpine, compiled helpers/render-template
from source, and ran it — to substitute three variables across a handful of
small text files. On a cold engine that dominated 'dagger module init python'.

Render in Dang instead: walk the template with glob, expand {{ .Var }} actions
in both paths and contents, and carry non-.tmpl files over as files so their
bytes and mode survive. Unknown variables raise rather than rendering empty.

camelName and splitWords reproduce the two distinct conversions the Go helper
took from strcase: ToCamel splits on separators only and lower-cases a letter
following another upper-case letter, while ToSnake splits camelCase humps and
keeps runs of capitals together. Output was verified byte-identical to the Go
helper across my-module, my_module, myModule, HTTPServer and simple, for each
of the default, empty and legacy templates.

ModuleImport is dropped: no Python template uses it, and an unknown variable
now raises, which documents its absence at the point of use.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The rendering assertions were all contains-only, so an extra, missing, or
misnamed output file passed. Assert the exact file set for the default and
legacy templates instead, which also covers the legacy template's .gitignore
and .gitattributes — non-template files that no check referenced before.

Add a naming check over every spelling a user might pass to init. The type and
package names come from two different conversions, and HTTPServer is the case
where they visibly disagree (Httpserver / http_server); pinning it keeps that
behaviour from drifting silently.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The helpers pinned golang:1.25-alpine while the polyfill dependency's Go
runtime pulls golang:1.26-alpine, so a cold engine pulled two Go base images
to run 'config get', 'config set', or an init with a non-default flag. Use the
same image for both; helpers/pyproject declares go 1.25.0, which 1.26 builds.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The default template's create() returns the class it is declared in. Deferred
annotations only became the default in 3.14 (PEP 649), so on anything older the
forward reference is evaluated eagerly and the module fails to load at all:

  dagger module init python app --python-version 3.13
  dagger call app container
  ModuleLoadError: name 'App' is not defined

That is a documented init flag producing a module that cannot be loaded, and
config set --python-version reaches the same state. Import annotations from
__future__ so the reference stays a string on every supported version.

Verified by initializing, generating and calling a module at 3.12, 3.13 and the
3.14 default. The import is free: repeated interleaved call benchmarks put it
within noise of the template without it (-13 ms and +10 ms across two batches).

Signed-off-by: Yves Brissaud <yves@dagger.io>
Rewrites the baseline against what the numbers and the reviews actually
support: the init win is ~1.2s on a cold engine and nothing on a warm one, the
polyfill commit-pin idea is dropped as a proven no-op, and the call-path
section replaces 'interpreter boot' with the measured split — interpreter start
is 8ms of a 1050ms process; the import chain, the first-connect handshake and
per-call bytecode recompilation are the real costs, all upstream.

Records the runtime-config sweep including the two arms deliberately not
adopted: python:3.14-alpine costs +24% per call, and use-uv=false saves 12% by
a mechanism nobody could isolate.

Signed-off-by: Yves Brissaud <yves@dagger.io>
`.ModuleName` was only ever read through pyproject.toml, and nothing asserted
that file was expanded, so a renderer regression that left it untouched passed
every check. Assert the rendered name and the absence of a leftover action.

Add `from __future__ import annotations` to the default template assertions:
dropping it from the template silently returns `init --python-version 3.13` to
producing a module that cannot be loaded.

Add an `s3-bucket` row to the naming check. It is the case the deleted Go
helper got wrong — `strcase` split on the digit and rendered `src/s_3_bucket/`,
which never matches the package `uv_build` derives from the project name.
While here, key each row's output path on the input name: kebab, snake and
camel spellings all shared one path and only the last write was observed.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Both helper containers pinned `golang:1.26-alpine` as a bare literal, with
nothing recording that the value tracks the image polyfill's own Go helpers
pull. Bind it once so the coupling is stated where it can be read.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The entry still pinned `golang:1.25-alpine`, so a frozen arm64 run had no lock
entry for the image the code asks for. Digest resolved from the tag as dagger
resolves it.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Fix the SHA cited for the Python 3.14 template fix, and record the digit-name
bug the renderer also fixed: `init` produced an unloadable module for any name
containing a digit, so the naming output diverges from `strcase` on purpose.

Correct the cross-SDK claim — go-sdk's copy of the helper had already forked
and sdk-sdk's is a different tool — and point consolidation at a Dang
`renderTemplate` primitive rather than another shared Go helper. Record the
template features the substituter dropped, qualify the file-mode equivalence
claim, scope goal 1 to this module's toolchain, state how the cold-init A/B was
ordered, and list what the review left deliberately unfixed.

Signed-off-by: Yves Brissaud <yves@dagger.io>
CI is green on the branch, so the work this doc governs is done. Move it to
hack/designs/done/ and record the final state, including the two null results
worth keeping: the polyfill commit-pin that provably changes nothing, and the
use-uv default that measures faster for reasons nobody could isolate.

Signed-off-by: Yves Brissaud <yves@dagger.io>
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