diff --git a/README.md b/README.md index 7f6330c..01e827c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,24 @@ string callResult = TemplateEngine.Process(callTmpl, new {}); ``` See the unit tests for more examples covering loops, conditionals and range expressions. The `YmlTemplateFileTest` shows how to render a full Kubernetes manifest from `tests/TestData/template.yml` with the expected output in `tests/TestData/expected.yml`. +## Benchmark Results + +The following microbenchmarks were run using [BenchmarkDotNet](https://benchmarkdotnet.org/) on .NET 9.0. Each benchmark renders the same short template: + +```text +Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }} +``` + +The model contains five strings in the `Items` list so every engine performs a small loop. BenchmarkDotNet ran each test using its default configuration which executes a warm‑up phase followed by enough iterations (13–96 in our runs) to collect roughly one second of timing data. The Go implementation was benchmarked with `go test -bench .` using the equivalent template and data. + +| Method | Mean | Error | StdDev | +|-------|------:|------:|------:| +| GoTextTemplate (.NET) | 14.52 us | 0.18 us | 0.15 us | +| Handlebars.Net | 1,857 us | 32 us | 29 us | +| Scriban | 14.62 us | 0.29 us | 0.81 us | +| DotLiquid | 13.79 us | 0.27 us | 0.28 us | +| Go text/template | 1.69 us | 0.00 us | 0.00 us | + ## Claude's suggestions https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf.js diff --git a/TextTemplate.sln b/TextTemplate.sln index 3bc0353..bcd52ad 100644 --- a/TextTemplate.sln +++ b/TextTemplate.sln @@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextTemplate", "src\TextTem EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextTemplate.Tests", "tests\TextTemplate.Tests\TextTemplate.Tests.csproj", "{DCC254BC-7E9C-4F0F-B5CB-14854AFAD207}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{66320409-64EC-F7C5-3DEF-65E7510DAAD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextTemplate.Benchmarks", "benchmarks\TextTemplate.Benchmarks\TextTemplate.Benchmarks.csproj", "{A43448CE-BD47-4886-9E48-AA006ABB4687}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,8 +45,23 @@ Global {DCC254BC-7E9C-4F0F-B5CB-14854AFAD207}.Release|x64.Build.0 = Release|Any CPU {DCC254BC-7E9C-4F0F-B5CB-14854AFAD207}.Release|x86.ActiveCfg = Release|Any CPU {DCC254BC-7E9C-4F0F-B5CB-14854AFAD207}.Release|x86.Build.0 = Release|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Debug|x64.ActiveCfg = Debug|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Debug|x64.Build.0 = Debug|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Debug|x86.ActiveCfg = Debug|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Debug|x86.Build.0 = Debug|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Release|Any CPU.Build.0 = Release|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Release|x64.ActiveCfg = Release|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Release|x64.Build.0 = Release|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Release|x86.ActiveCfg = Release|Any CPU + {A43448CE-BD47-4886-9E48-AA006ABB4687}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A43448CE-BD47-4886-9E48-AA006ABB4687} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} + EndGlobalSection EndGlobal diff --git a/benchmarks/TextTemplate.Benchmarks/Program.cs b/benchmarks/TextTemplate.Benchmarks/Program.cs new file mode 100644 index 0000000..a0b1fcf --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/Program.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Running; +using TextTemplate; +using HandlebarsDotNet; +using Scriban; +using DotLiquid; +using ScribanTemplateClass = Scriban.Template; +using DotLiquidTemplateClass = DotLiquid.Template; +using Hbs = HandlebarsDotNet.Handlebars; + +public class TemplateBenchmarks +{ + private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}"; + private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}"; + private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}"; + private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}"; + + private Dictionary _model = null!; + + [GlobalSetup] + public void Setup() + { + _model = new Dictionary + { + ["Name"] = "Bob", + ["Items"] = new List { "one", "two", "three", "four", "five" } + }; + DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention(); + } + + [Benchmark] + public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model); + + [Benchmark] + public string Handlebars() => Hbs.Compile(HBTemplate)(_model); + + [Benchmark] + public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model); + + [Benchmark] + public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model)); +} + +public class Program +{ + public static void Main(string[] args) => BenchmarkRunner.Run(); +} diff --git a/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj b/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj new file mode 100644 index 0000000..990c479 --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/benchmarks/go/go.mod b/benchmarks/go/go.mod new file mode 100644 index 0000000..8e9f606 --- /dev/null +++ b/benchmarks/go/go.mod @@ -0,0 +1,3 @@ +module benchmarks + +go 1.22 diff --git a/benchmarks/go/template_test.go b/benchmarks/go/template_test.go new file mode 100644 index 0000000..4f4d723 --- /dev/null +++ b/benchmarks/go/template_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" + "text/template" + "bytes" +) + +var goTmpl = template.Must(template.New("t").Parse("Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}")) +var goData = map[string]any{ + "Name": "Bob", + "Items": []string{"one", "two", "three", "four", "five"}, +} + +func BenchmarkGoTextTemplate(b *testing.B) { + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + goTmpl.Execute(&buf, goData) + _ = buf.String() + } +} diff --git a/tests/TextTemplate.Tests/AllFeaturesStressTests.cs b/tests/TextTemplate.Tests/AllFeaturesStressTests.cs new file mode 100644 index 0000000..fe943eb --- /dev/null +++ b/tests/TextTemplate.Tests/AllFeaturesStressTests.cs @@ -0,0 +1,43 @@ +using Xunit; +using Shouldly; +using TextTemplate; +using System.Collections.Generic; + +namespace TextTemplate.Tests; + +public class AllFeaturesStressTests +{ + [Fact] + public void AllFeatures_ExecutedManyTimes() + { + const string tmpl = @"{{$greeting := printf ""Hi %s"" .Name}} +{{ with .User }}{{ $greeting }}, {{ .Name }}!{{ else }}{{ $greeting }}{{ end }} +{{ if lt .Count 10 }}small{{ else }}big{{ end }} +Items: {{ range $i, $v := .Items }}{{ $i }}={{ $v }},{{ end }} +Len={{ len .Items }} +First={{ index .Items 0 }} +Slice={{ slice .Items 1 3 | print }} +Html={{ .Raw | html }} +Url={{ .UrlValue | urlquery }} +Sum={{ call ""Add"" 2 3 }}"; + + TemplateEngine.RegisterFunction("Add", new Func((a, b) => a + b)); + var model = new Dictionary + { + ["Name"] = "Bob", + ["User"] = new Dictionary { ["Name"] = "Alice" }, + ["Count"] = 3, + ["Items"] = new[] { "a", "b", "c" }, + ["Raw"] = "x", + ["JsSrc"] = "a && b", + ["UrlValue"] = "a b&c" + }; + + var expected = TemplateEngine.Process(tmpl, model); + for (int i = 0; i < 1000; i++) + { + var result = TemplateEngine.Process(tmpl, model); + result.ShouldBe(expected); + } + } +}