Skip to content

Commit 8f171f8

Browse files
committed
eval: parallelize environment loading
These changes add support for loading environments in parallel. Parallelization is breadth-first, then depth-first. For example, given the following environments: ```yaml imports: # env a - b - c -- imports; # env b - e -- imports: # env c - f - g ``` Environments `b` and `c` would be loaded in parallel, then environment `e` would be loaded, then environments `f` and `g` would be loaded in parallel. This simplifies the detection of cyclic imports and the collection of diagnostics. This improves performance for scenarios that are dominated by environment load time (e.g. import graphs with high degrees of fanout). Local benchmark results: goos: darwin goarch: arm64 pkg: github.com/pulumi/esc/eval cpu: Apple M1 Max BenchmarkEval-10 162 6249736 ns/op 4636941 B/op 23697 allocs/op BenchmarkEval-10 192 6229592 ns/op 4637483 B/op 23697 allocs/op BenchmarkEval-10 192 6213844 ns/op 4638117 B/op 23699 allocs/op BenchmarkEval-10 192 6215693 ns/op 4637189 B/op 23696 allocs/op BenchmarkEval-10 192 6286186 ns/op 4637032 B/op 23696 allocs/op BenchmarkEval-10 192 6250083 ns/op 4637796 B/op 23698 allocs/op BenchmarkEval-10 194 6201700 ns/op 4637262 B/op 23697 allocs/op BenchmarkEval-10 192 6256509 ns/op 4637151 B/op 23697 allocs/op BenchmarkEval-10 193 6220107 ns/op 4638638 B/op 23699 allocs/op BenchmarkEval-10 192 6196454 ns/op 4636411 B/op 23696 allocs/op BenchmarkEvalOpen-10 9 124156394 ns/op 4632673 B/op 23703 allocs/op BenchmarkEvalOpen-10 9 123760278 ns/op 4636146 B/op 23713 allocs/op BenchmarkEvalOpen-10 9 123941329 ns/op 4640512 B/op 23718 allocs/op BenchmarkEvalOpen-10 9 122636315 ns/op 4637026 B/op 23710 allocs/op BenchmarkEvalOpen-10 9 123189880 ns/op 4636938 B/op 23702 allocs/op BenchmarkEvalOpen-10 9 122790926 ns/op 4635157 B/op 23710 allocs/op BenchmarkEvalOpen-10 9 123945481 ns/op 4637705 B/op 23711 allocs/op BenchmarkEvalOpen-10 9 123235093 ns/op 4640275 B/op 23710 allocs/op BenchmarkEvalOpen-10 9 122647329 ns/op 4639136 B/op 23708 allocs/op BenchmarkEvalOpen-10 9 123545866 ns/op 4638529 B/op 23715 allocs/op BenchmarkEvalEnvLoad-10 60 17289035 ns/op 4639187 B/op 23721 allocs/op BenchmarkEvalEnvLoad-10 72 17035170 ns/op 4638442 B/op 23721 allocs/op BenchmarkEvalEnvLoad-10 72 17176440 ns/op 4639614 B/op 23722 allocs/op BenchmarkEvalEnvLoad-10 72 17151064 ns/op 4639322 B/op 23722 allocs/op BenchmarkEvalEnvLoad-10 70 17086652 ns/op 4638750 B/op 23719 allocs/op BenchmarkEvalEnvLoad-10 70 16979492 ns/op 4638801 B/op 23721 allocs/op BenchmarkEvalEnvLoad-10 70 17061474 ns/op 4638384 B/op 23720 allocs/op BenchmarkEvalEnvLoad-10 70 17121283 ns/op 4640587 B/op 23723 allocs/op BenchmarkEvalEnvLoad-10 69 17093001 ns/op 4639506 B/op 23722 allocs/op BenchmarkEvalEnvLoad-10 70 17046285 ns/op 4639028 B/op 23720 allocs/op BenchmarkEvalAll-10 8 135173646 ns/op 4635106 B/op 23730 allocs/op BenchmarkEvalAll-10 8 133903672 ns/op 4638396 B/op 23735 allocs/op BenchmarkEvalAll-10 8 133961172 ns/op 4640463 B/op 23735 allocs/op BenchmarkEvalAll-10 8 134953359 ns/op 4639089 B/op 23724 allocs/op BenchmarkEvalAll-10 8 134443724 ns/op 4639118 B/op 23739 allocs/op BenchmarkEvalAll-10 8 134042062 ns/op 4638356 B/op 23733 allocs/op BenchmarkEvalAll-10 8 135336984 ns/op 4642280 B/op 23739 allocs/op BenchmarkEvalAll-10 8 135161682 ns/op 4638414 B/op 23730 allocs/op BenchmarkEvalAll-10 8 133572880 ns/op 4639524 B/op 23727 allocs/op BenchmarkEvalAll-10 8 137661594 ns/op 4642510 B/op 23736 allocs/op
1 parent 840f7c0 commit 8f171f8

File tree

5 files changed

+164
-828
lines changed

5 files changed

+164
-828
lines changed

eval/eval.go

Lines changed: 89 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,14 @@ func evalEnvironment(
113113
return nil, nil
114114
}
115115

116-
ec := newEvalContext(ctx, validating, name, env, decrypter, providers, envs, map[string]*imported{}, execContext, showSecrets)
116+
loader := newLoader(ctx, envs)
117+
ec := newEvalContext(ctx, validating, name, env, decrypter, providers, loader, map[string]*imported{}, execContext, showSecrets)
118+
119+
diags := ec.load()
120+
if diags.HasErrors() {
121+
return nil, diags
122+
}
123+
117124
v, diags := ec.evaluate()
118125

119126
s := schema.Never().Schema()
@@ -139,22 +146,23 @@ func evalEnvironment(
139146
}
140147

141148
type imported struct {
142-
evaluating bool
143-
value *value
149+
loading bool
150+
ctx *evalContext
151+
value *value
144152
}
145153

146154
// An evalContext carries the state necessary to evaluate an environment.
147155
type evalContext struct {
148-
ctx context.Context // the cancellation context for evaluation
149-
validating bool // true if we are only checking the environment
150-
showSecrets bool // true if secrets should be decrypted during validation
151-
name string // the name of the environment
152-
env *ast.EnvironmentDecl // the root of the environment AST
153-
decrypter Decrypter // the decrypter to use for the environment
154-
providers ProviderLoader // the provider loader to use
155-
environments EnvironmentLoader // the environment loader to use
156-
imports map[string]*imported // the shared set of imported environments
157-
execContext *esc.ExecContext // evaluation context used for interpolation
156+
ctx context.Context // the cancellation context for evaluation
157+
validating bool // true if we are only checking the environment
158+
showSecrets bool // true if secrets should be decrypted during validation
159+
name string // the name of the environment
160+
env *ast.EnvironmentDecl // the root of the environment AST
161+
decrypter Decrypter // the decrypter to use for the environment
162+
providers ProviderLoader // the provider loader to use
163+
loader *loader // the environment loader
164+
imports map[string]*imported // the shared set of imported environments
165+
execContext *esc.ExecContext // evaluation context used for interpolation
158166

159167
myContext *value // evaluated context to be used to interpolate properties
160168
myImports *value // directly-imported environments
@@ -171,22 +179,22 @@ func newEvalContext(
171179
env *ast.EnvironmentDecl,
172180
decrypter Decrypter,
173181
providers ProviderLoader,
174-
environments EnvironmentLoader,
182+
loader *loader,
175183
imports map[string]*imported,
176184
execContext *esc.ExecContext,
177185
showSecrets bool,
178186
) *evalContext {
179187
return &evalContext{
180-
ctx: ctx,
181-
validating: validating,
182-
showSecrets: showSecrets,
183-
name: name,
184-
env: env,
185-
decrypter: decrypter,
186-
providers: providers,
187-
environments: environments,
188-
imports: imports,
189-
execContext: execContext.CopyForEnv(name),
188+
ctx: ctx,
189+
validating: validating,
190+
showSecrets: showSecrets,
191+
name: name,
192+
env: env,
193+
decrypter: decrypter,
194+
providers: providers,
195+
loader: loader,
196+
imports: imports,
197+
execContext: execContext.CopyForEnv(name),
190198
}
191199
}
192200

@@ -349,6 +357,55 @@ func (e *evalContext) isReserveTopLevelKey(k string) bool {
349357
}
350358
}
351359

360+
func (e *evalContext) load() syntax.Diagnostics {
361+
mine := &imported{loading: true, ctx: e}
362+
defer func() { mine.loading = false }()
363+
e.imports[e.name] = mine
364+
365+
loads := make([]*loadedEnvironment, len(e.env.Imports.GetElements()))
366+
for i, entry := range e.env.Imports.GetElements() {
367+
loads[i] = e.loadImport(entry)
368+
}
369+
370+
for i, entry := range e.env.Imports.GetElements() {
371+
l := loads[i]
372+
if l == nil {
373+
continue
374+
}
375+
<-l.done
376+
377+
e.diags.Extend(l.diags...)
378+
if l.err != nil {
379+
e.errorf(entry.Environment, "%s", l.err.Error())
380+
continue
381+
}
382+
383+
imp := newEvalContext(e.ctx, e.validating, l.name, l.env, l.dec, e.providers, e.loader, e.imports, e.execContext, e.showSecrets)
384+
diags := imp.load()
385+
e.diags.Extend(diags...)
386+
}
387+
388+
return e.diags
389+
}
390+
391+
func (e *evalContext) loadImport(decl *ast.ImportDecl) *loadedEnvironment {
392+
// If the import does not have a name, there's nothing we can do. This can happen for environments
393+
// with parse errors.
394+
if decl.Environment == nil {
395+
return nil
396+
}
397+
name := decl.Environment.Value
398+
399+
if imported, ok := e.imports[name]; ok {
400+
if imported.loading {
401+
e.diags.Extend(syntax.Error(decl.Syntax().Syntax().Range(), fmt.Sprintf("cyclic import of %v", name), decl.Syntax().Syntax().Path()))
402+
}
403+
return nil
404+
}
405+
406+
return e.loader.load(name)
407+
}
408+
352409
// evaluate drives the evaluation of the evalContext's environment.
353410
func (e *evalContext) evaluate() (*value, syntax.Diagnostics) {
354411
// Evaluate context. We prepare the context values to later evaluate interpolations.
@@ -393,10 +450,6 @@ func (e *evalContext) evaluateContext() {
393450

394451
// evaluateImports evaluates an environment's imports.
395452
func (e *evalContext) evaluateImports() {
396-
mine := &imported{evaluating: true}
397-
defer func() { mine.evaluating = false }()
398-
e.imports[e.name] = mine
399-
400453
myImports := map[string]*value{}
401454
for _, entry := range e.env.Imports.GetElements() {
402455
e.evaluateImport(myImports, entry)
@@ -432,34 +485,22 @@ func (e *evalContext) evaluateImport(myImports map[string]*value, decl *ast.Impo
432485
}
433486
name := decl.Environment.Value
434487

488+
imported, ok := e.imports[name]
489+
if !ok {
490+
e.diags.Extend(syntax.Error(decl.Syntax().Syntax().Range(), fmt.Sprintf("internal error: missing context for %v", name), decl.Syntax().Syntax().Path()))
491+
return
492+
}
493+
435494
merge := true
436495
if decl.Meta != nil && decl.Meta.Merge != nil {
437496
merge = decl.Meta.Merge.Value
438497
}
439498

440499
var val *value
441-
if imported, ok := e.imports[name]; ok {
442-
if imported.evaluating {
443-
e.diags.Extend(syntax.Error(decl.Syntax().Syntax().Range(), fmt.Sprintf("cyclic import of %v", name), decl.Syntax().Syntax().Path()))
444-
return
445-
}
500+
if imported.value != nil {
446501
val = imported.value
447502
} else {
448-
bytes, dec, err := e.environments.LoadEnvironment(e.ctx, name)
449-
if err != nil {
450-
e.errorf(decl.Environment, "%s", err.Error())
451-
return
452-
}
453-
454-
env, diags, err := LoadYAMLBytes(name, bytes)
455-
e.diags.Extend(diags...)
456-
if err != nil {
457-
e.errorf(decl.Environment, "%s", err.Error())
458-
return
459-
}
460-
461-
imp := newEvalContext(e.ctx, e.validating, name, env, dec, e.providers, e.environments, e.imports, e.execContext, e.showSecrets)
462-
v, diags := imp.evaluate()
503+
v, diags := imported.ctx.evaluate()
463504
e.diags.Extend(diags...)
464505

465506
val = v

eval/eval_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,8 @@ func benchmarkEval(b *testing.B, openDelay, loadDelay time.Duration) {
405405
envs, err := newBenchEnvironments(basePath, loadDelay)
406406
require.NoError(b, err)
407407

408+
b.ResetTimer()
409+
408410
for i := 0; i < b.N; i++ {
409411
execContext, err := esc.NewExecContext(map[string]esc.Value{
410412
"pulumi": esc.NewValue(map[string]esc.Value{

eval/loader.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2024, Pulumi Corporation.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package eval
16+
17+
import (
18+
"context"
19+
20+
"github.com/pulumi/esc/ast"
21+
"github.com/pulumi/esc/syntax"
22+
)
23+
24+
type loadedEnvironment struct {
25+
done <-chan bool
26+
27+
name string
28+
env *ast.EnvironmentDecl
29+
dec Decrypter
30+
diags syntax.Diagnostics
31+
err error
32+
}
33+
34+
type loader struct {
35+
ctx context.Context
36+
environments EnvironmentLoader
37+
loaded map[string]*loadedEnvironment
38+
}
39+
40+
func newLoader(ctx context.Context, environments EnvironmentLoader) *loader {
41+
return &loader{
42+
ctx: ctx,
43+
environments: environments,
44+
loaded: map[string]*loadedEnvironment{},
45+
}
46+
}
47+
48+
func (l *loader) load(name string) *loadedEnvironment {
49+
if loaded, ok := l.loaded[name]; ok {
50+
return loaded
51+
}
52+
53+
done := make(chan bool)
54+
result := &loadedEnvironment{done: done, name: name}
55+
go func() {
56+
defer close(done)
57+
58+
bytes, dec, err := l.environments.LoadEnvironment(l.ctx, name)
59+
if err != nil {
60+
result.err = err
61+
return
62+
}
63+
result.dec = dec
64+
65+
result.env, result.diags, result.err = LoadYAMLBytes(name, bytes)
66+
return
67+
}()
68+
69+
l.loaded[name] = result
70+
return result
71+
}

0 commit comments

Comments
 (0)