Skip to content

Conversation

thevilledev
Copy link
Contributor

@thevilledev thevilledev commented Apr 18, 2025

Pre-compute Sprig template functions during package init. Avoids repeated map allocation and string concatenation for Sprig functions on every template execution, addressing performance overhead reported in #2031.

In addition, use HermeticTxtFuncMap which only imports repeatable functions. This is primarily to avoid potentially malicious input. See implementation here.

Copy link
Member

@jrasell jrasell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @thevilledev and thanks for raising this PR.

I am not a code owner of this repository, so don't have the final say at the moment. I did want to note that while this init function avoids the string concatenation overhead, this will not avoid the map regrowth cost also seen. It would be nice if we could look into this too, even if the investigation determines avoiding this is not possible.

I am also curious on your thoughts of how using a private global variable and init function might impact applications such as Nomad, which utilise consul-template to run and manage any number of templates on a single host via a single control application.

In the PR code at the moment, it would be possible to create the funcmap with a fixed/known size, as it's possible to know the size of the sprig map before creating this I believe. This would avoid unnecessary map growth work in this area.

@thevilledev thevilledev force-pushed the feat/optimize-sprig-func branch from c1b7bf7 to 19f8b23 Compare April 23, 2025 14:15
@thevilledev
Copy link
Contributor Author

Hey @jrasell!

I did want to note that while this init function avoids the string concatenation overhead, this will not avoid the map regrowth cost also seen. It would be nice if we could look into this too, even if the investigation determines avoiding this is not possible.

funcMap is called once per template execution. The map must be created or significantly modified on each execution because the functions it contains are closures. They depend on template specific things, which is especially true for any of the Nomad specific functions (like nomadServices).

Because these functions capture variables (i.brain, i.used, i.env,i.destination, etc.) from the specific funcMapInput structure, it's not possible to simply create one global function map containing these functions. Doing so would cause all template executions to incorrectly share or use stale state (brain, used/missing sets, environment variables, destination paths) from whichever execution happened to create the global map first.

I am also curious on your thoughts of how using a private global variable and init function might impact applications such as Nomad, which utilise consul-template to run and manage any number of templates on a single host via a single control application.

I think this will be a nice improvement for scenarios where templates are executed in masses. The most expensive part of the funcMap function was about iterating through those 210 functions, doing string concatenations and inserting every time. Now Nomad can initialise the sprig functions exactly once upon package init, when the process starts, and upon template executions some copying is needed for the reasons mentioned above.

I prepared a Go benchmark for this:

package main

import (
	"testing"

	"github.com/hashicorp/consul-template/template"
)

const benchmarkTemplateContent = `{{ sprig_uuidv4 }}`

func BenchmarkTemplateExecute(b *testing.B) {
	brain := template.NewBrain()
	tmplInput := &template.NewTemplateInput{
		Contents: benchmarkTemplateContent,
	}
	tmpl, err := template.NewTemplate(tmplInput)
	if err != nil {
		b.Fatalf("Setup: Failed to create template: %v", err)
	}
	execInput := &template.ExecuteInput{
		Brain: brain,
	}
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_, err := tmpl.Execute(execInput)
		if err != nil {
			b.Fatalf("Execution failed during benchmark: %v", err)
		}
	}
}

Results with go test -bench=. -benchmem -count=5 on Apple M1 Pro:

Main branch:

cpu: Apple M1 Pro
BenchmarkTemplateExecute-8         12168             97964 ns/op          131559 B/op        322 allocs/op
BenchmarkTemplateExecute-8         12291             97795 ns/op          131560 B/op        322 allocs/op
BenchmarkTemplateExecute-8         12235             97701 ns/op          131559 B/op        322 allocs/op
BenchmarkTemplateExecute-8         12276             98948 ns/op          131559 B/op        322 allocs/op
BenchmarkTemplateExecute-8         12171             98465 ns/op          131559 B/op        322 allocs/op

Proposed fix branch:

cpu: Apple M1 Pro
BenchmarkTemplateExecute-8         13532             89330 ns/op          118319 B/op        109 allocs/op
BenchmarkTemplateExecute-8         13861             86195 ns/op          118318 B/op        109 allocs/op
BenchmarkTemplateExecute-8         13850             86305 ns/op          118319 B/op        109 allocs/op
BenchmarkTemplateExecute-8         13882             86529 ns/op          118319 B/op        109 allocs/op
BenchmarkTemplateExecute-8         14067             85905 ns/op          118319 B/op        109 allocs/op

That's roughly 10% improvement on execution time and memory usage, and whopping 66% reduction in memory allocations.

In the PR code at the moment, it would be possible to create the funcmap with a fixed/known size, as it's possible to know the size of the sprig map before creating this I believe. This would avoid unnecessary map growth work in this area.

Since the init function is ran exactly once I think this won't affect the performance that much, but it's good practice anyway. Pushed a fix, thanks.

Copy link
Contributor

@dduzgun-security dduzgun-security left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for your contribution @thevilledev 🙌

Added a small comment and will ask the team to review + add a deprecating note on sprig functions that HemerticTxtFuncMap removes before cutting a new release.

@thevilledev thevilledev force-pushed the feat/optimize-sprig-func branch from 19f8b23 to 9976ad1 Compare May 22, 2025 20:28
@thevilledev thevilledev changed the title feat(template): optimize sprig function init feat(template): optimize and deprecate sprig functions May 22, 2025
@thevilledev thevilledev force-pushed the feat/optimize-sprig-func branch 2 times, most recently from d899c36 to 910b523 Compare May 22, 2025 20:42
@thevilledev
Copy link
Contributor Author

Consul Enterprise related test is fixed in #2053 - but even with the fix it fails, possibly due to an outdated license in the repository secrets.

Pre-compute Sprig template functions during package init.
Avoids repeated map allocation and string concatenation
for Sprig functions on every template execution, addressing
performance overhead reported in hashicorp#2031.

In addition, use HermeticTxtFuncMap which only imports
repeatable functions. This is primarily to avoid potentially
malicious input.

Signed-off-by: Ville Vesilehto <ville@vesilehto.fi>
@thevilledev thevilledev force-pushed the feat/optimize-sprig-func branch from 910b523 to dbe0d4a Compare May 23, 2025 05:07
@Vikramarjuna
Copy link

LGTM

Copy link
Contributor

@dduzgun-security dduzgun-security left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 👍 Thanks a lot for the contribution. Seems like the CI is having a bit of a hard time but I'll rerun + look at it early next week to see if it's resolved prior to merging.

@dduzgun-security dduzgun-security merged commit 21bd866 into hashicorp:main May 30, 2025
27 of 28 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.

4 participants