Visitar URL original
perf(core): reuse compiled Dang programs within a session and parse module source once by grouville · Pull Request #14953 · dagger/dagger · GitHub
Skip to content

perf(core): reuse compiled Dang programs within a session and parse module source once - #14953

Merged
sipsma merged 10 commits into
dagger:mainfrom
grouville:perf/prepared-dang-compilation-20261029
Nov 3, 2026
Merged

sipsma merged 10 commits into
dagger:mainfrom
grouville:perf/prepared-dang-compilation-20261029

Conversation

@grouville

@grouville grouville commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Dang module function calls stop re-checking their module on every call, and loading a Dang module parses its source once instead of three times. To get there, the engine moves Dang to its current main, which brings a stricter type checker: some Dang modules that load today will fail to load with a type error.

What changes for Dang module authors

Dang moves to the merge of vito/dang#250 on Dang main (v2.3.1-0.20261103042248-00dc3801b409). Along with the v2.2.0 and v2.3.0 releases, this brings:

  • A stricter type checker (Fix soundness holes in builtins, codecs and GraphQL values vito/dang#230 to Implemented cli query command #235, Don't print the config at the end of 'dagger up' #246, tests: split integration tests #249). Programs it used to accept that could fail at runtime are now type errors. The common cases:
    • arithmetic and ordering operators need non-null operands: xs[0] + 1 needs !, ?? or a null check;
    • Map.get returns a nullable value (Type Map.get as nullable vito/dang#215);
    • the result of JSON.decode, YAML.decode or TOML.decode must be given a type, by an annotation, an argument or ::, before its fields or operators are used;
    • a top-level declaration's initializer may not write another top-level binding.
  • Function parameters match by name. A block passed for a function-typed parameter takes that parameter's names. A positional call through an interface binds arguments in the interface's parameter order, so a call to an implementation that declares its parameters in another order can now return a different result.
  • The governing dang.toml sets the Dang version. That's the nearest dang.toml, walking up to the repository root. A malformed dang.toml, which was ignored before, now fails the module's load. The engine still chooses a module's Dagger API version itself.
  • A module's own top-level declarations shadow builtins of the same name. JSON, YAML and TOML decode into Map types, with YAML << merge keys resolved. Malformed syntax is reported as an error instead of crashing the parser.

The release note describes these.

The engine's Dang SDK follows one of these changes itself. Since vito/dang#235, Dang binds an interface's name to the interface's namespace instead of the interface type, so the engine now registers a module's interfaces through the type that namespace describes. Without that, a module's own interfaces were missing from its API, and a module implementing a dependency's interface failed to load.

Compiled Dang programs are reused within a session

Before, every function call into a Dang module parsed and type-checked the whole module again before running the function. A collection makes one call per item, such as one per Go test, so listing thousands of tests type-checked the same module thousands of times.

Now the first call in a session type-checks the module with Dang's PrepareDir and keeps the result. Later calls in the same session reuse it when the module source, API schema, module names and mount path all match. The kept program is held in the session's cache and released with the session.

  • Calls stay independent. Each call evaluates a fresh copy (PreparedDirectory.Evaluate), runs the module's initializers and binds its own API client.
  • Caching doesn't change. Only compiler output is kept, never function results, so result cache keys and the identities of collection items don't change.
  • Failures: concurrent first calls share one compilation. A failed compilation isn't kept, so the next call tries again. A call whose evaluation fails returns its error without running again, so a function's side effects never run twice.
  • Fallback: modules Dang can't prepare (scalar declarations, static namespaces, imports of other Dang modules) run as before. So does the call that registers a module's types.
  • Shared schema: every call of a session now shares the cached API schema, instead of each getting its own copy. That lets the reused program recognize the schema it was compiled with by pointer, instead of comparing the two schemas' type lists on every call. Adding a module's own types to the schema, which only happens while registering its types, now works on a copy.

A module's source is parsed once when it loads

Loading a Dang module parsed its source three times: for the module's own type names, in DeclareDir, and again to put its object directives back on its types. Only DeclareDir's parse went through the engine's Dang syntax cache. The engine's own two reads now go through the same cache with dang.ParseSourceFile, at the module's Dang version. For dagger/go's Go module, each extra parse cost 147 ms and 65 MB of allocations, against 6 ms and 2 MB to copy from the cache.

The memo of a module's declared type names now also keys on the Dang version, since the names' parse follows the module's dang.toml.

Measured

On the three full listings of dagger/dagger's 4,953 Go tests, on top of other pending performance changes, with and without the compiled-program reuse (3 pairs on a 16-CPU machine, every run's output checked against go test -list):

Listing Without With Saved, 95% interval
First, on a fresh engine 85.4 s 82.7 s 2.95 s [2.68, 3.29]
Unchanged 5.5 s 4.2 s 1.34 s [1.29, 1.39]
After an edit and an added test 16.4 s 14.9 s 1.66 s [1.42, 1.82]

Engine CPU time on these listings falls 11 to 42%. Parsing each module's source once saved 0.60 s on the fresh listing in the same setup.

Tests

  • TestPreparedDang… covers one shared compilation across twelve concurrent clients, each client seeing its own values, every input of the cache key, a retry after a failed compilation, and release with the session.
  • The schema cache test checks that calls share one schema and that a module's own types never enter it.
  • The parse tests count parses through the syntax cache, and cover a broken dang.toml being repaired without the source changing.
  • All Dang SDK package tests (./core/sdk/dang/...) pass with the race detector at this head.

Supersedes #14998, whose four commits are included here.

sipsma and others added 9 commits November 2, 2026 22:22
Dang moves from the merge of vito/dang#205 to the merge of vito/dang#213,
which adds ParseSourceFile. In between are the v2.2.0 and v2.3.0 releases
and vito/dang#214, which change what Dang modules see:

- The dang.toml that governs a module's source, found by walking up to the
  repository root, now sets the Dang version its source parses and
  evaluates as. A module with none keeps the newest version. A malformed
  dang.toml, which was ignored before, now fails the module's load.
- A module's own top-level declarations shadow builtins of the same name
  (vito/dang#212).
- JSON, YAML and TOML decode into Map[T] (vito/dang#214).
- The Dang language skill the engine serves describes these.

The engine still chooses a module's Dagger API version itself; the
Dagger version a dang.toml can declare doesn't change it.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
Loading a Dang module parses its source three times: once for its declared
type names, once in DeclareDir, and once more to put its object directives
back on its types. Only DeclareDir's parse went through the syntax cache,
so on a fresh engine every module paid for two extra parses before its
first call. For dagger/go's Go module each costs 147 ms and 65 MB of
allocations, against 6 ms and 2 MB to copy the file from the cache.

The engine's own two reads now parse through the same cache with
dang.ParseSourceFile, at the module's Dang version, so a module's source
is parsed once for all three.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
Their parse now follows the module's dang.toml, so the names depend on the
Dang version as well as the source: a module whose dang.toml was broken,
or that changed only its version, kept the names found before. The memo
key now includes the version, and a dang.toml that can't be read is not
remembered.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
Signed-off-by: Erik Sipsma <erik@sipsma.dev>
Signed-off-by: Guillaume de Rouville <guillaume@dagger.io>
Signed-off-by: Guillaume de Rouville <guillaume@dagger.io>
Signed-off-by: Guillaume de Rouville <guillaume@dagger.io>
…by pointer

A Dang call that reuses its module's prepared program checks that the
Dagger import's schema is the one it was prepared with: by pointer, and
otherwise by comparing the two. cachedDangSchema gave every call its own
copy of the schema, so the pointer never matched and every call compared
the type lists.

The copy was there only because ensureModuleSelfTypes appended the
module's own types in place. Return the cached schema itself, and have
ensureModuleSelfTypes add missing types to a copy it returns. At run
time the schema already has those types, so calls share one schema.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
The engine pinned Guillaume's fork of Dang for vito/dang#211, which adds
PrepareDir and PreparedDirectory.Evaluate. vito/dang#250 carried that
change onto Dang main and merged, so Dang now moves to its merge commit
and the replace of the fork goes away.

Since the merge of vito/dang#213 that Dang was on before, Dang's type
checker became stricter (vito/dang#230 to #235, #246 and #249): programs
it accepted that could fail at runtime are now type errors, so a module
that loaded before may now fail to load. Among them, operators need
non-null operands, Map.get is typed as nullable (vito/dang#215), and a
decode result must be given a type before it is used. Function
parameters match by name, so a positional call through an interface
binds in the interface's order. YAML << merge keys are resolved when
decoding (vito/dang#217), and malformed syntax is reported instead of
panicking the parser (vito/dang#243). The release note describes what
module authors will see.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
@sipsma
sipsma force-pushed the perf/prepared-dang-compilation-20261029 branch from 97907f3 to 5649b1f Compare November 3, 2026 04:47
@sipsma sipsma changed the title perf(core): reuse completed Dang compilation within a session perf(core): reuse compiled Dang programs within a session and parse module source once Nov 3, 2026
@sipsma
sipsma marked this pull request as ready for review November 3, 2026 04:48
Since vito/dang#235 (761f6710), Dang binds an interface's name to its
namespace: an object of the interface's meta-type, which is an object
type with no members. Registering a module's types finds its interfaces
by the kind of each public binding's type, so it skipped them all. A
module's own interfaces were missing from its API, and its consumers
failed to load: "interface DepGreeter not found" for a type implementing
a dependency's interface.

Use the type a meta-type describes, the interface itself, both to find
the interface and to build its type definition.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
@sipsma
sipsma merged commit 7a34ef9 into dagger:main Nov 3, 2026
95 checks passed
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