tpl: Add a partial lookup cache

````
                 │ stash.bench  │          perf-v146.bench           │
                 │    sec/op    │   sec/op     vs base               │
LookupPartial-10   248.00n ± 0%   14.75n ± 2%  -94.05% (p=0.002 n=6)

                 │ stash.bench │          perf-v146.bench          │
                 │    B/op     │   B/op     vs base                │
LookupPartial-10    48.00 ± 0%   0.00 ± 0%  -100.00% (p=0.002 n=6)

                 │ stash.bench │          perf-v146.bench           │
                 │  allocs/op  │ allocs/op   vs base                │
LookupPartial-10    3.000 ± 0%   0.000 ± 0%  -100.00% (p=0.002 n=6)
```

THe speedup above assumes reuse of the same partials over and over again, which I think is not uncommon.

This commits also adds some more lookup benchmarks. The current output of these on my MacBook looks decent:

```
BenchmarkLookupPagesLayout/Single_root-10                3031562               395.5 ns/op             0 B/op          0 allocs/op
BenchmarkLookupPagesLayout/Single_sub_folder-10          2515915               480.9 ns/op             0 B/op          0 allocs/op
BenchmarkLookupPartial-10                               84808112                14.13 ns/op            0 B/op          0 allocs/op
BenchmarkLookupShortcode/toplevelpage-10                 8111779               148.2 ns/op             0 B/op          0 allocs/op
BenchmarkLookupShortcode/nestedpage-10                   8088183               148.6 ns/op             0 B/op          0 allocs/op
```

Note that in the above the partial lookups are cahced, the others not (they are harder to cache because of the page path).

Closes #13571
This commit is contained in:
Bjørn Erik Pedersen 2025-04-10 09:22:29 +02:00
parent 18d2d2f985
commit 208a0de6c3
11 changed files with 158 additions and 73 deletions

View file

@ -160,7 +160,7 @@ func (c *Cache[K, T]) Len() int {
func (c *Cache[K, T]) Reset() { func (c *Cache[K, T]) Reset() {
c.Lock() c.Lock()
c.m = make(map[K]T) clear(c.m)
c.hasBeenInitialized = false c.hasBeenInitialized = false
c.Unlock() c.Unlock()
} }

View file

@ -51,7 +51,7 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page) (io.Reader, err
var templateDesc tplimpl.TemplateDescriptor var templateDesc tplimpl.TemplateDescriptor
var base string = "" var base string = ""
if ps, ok := p.(*pageState); ok { if ps, ok := p.(*pageState); ok {
base, templateDesc = ps.getTemplateBasePathAndDescriptor() base, templateDesc = ps.GetInternalTemplateBasePathAndDescriptor()
} }
templateDesc.Layout = "" templateDesc.Layout = ""
templateDesc.Kind = "" templateDesc.Kind = ""

View file

@ -476,7 +476,8 @@ func (ps *pageState) initCommonProviders(pp pagePaths) error {
return nil return nil
} }
func (po *pageOutput) getTemplateBasePathAndDescriptor() (string, tplimpl.TemplateDescriptor) { // Exported so it can be used in integration tests.
func (po *pageOutput) GetInternalTemplateBasePathAndDescriptor() (string, tplimpl.TemplateDescriptor) {
p := po.p p := po.p
f := po.f f := po.f
base := p.PathInfo().BaseReTyped(p.m.pageConfig.Type) base := p.PathInfo().BaseReTyped(p.m.pageConfig.Type)
@ -491,7 +492,7 @@ func (po *pageOutput) getTemplateBasePathAndDescriptor() (string, tplimpl.Templa
} }
func (p *pageState) resolveTemplate(layouts ...string) (*tplimpl.TemplInfo, bool, error) { func (p *pageState) resolveTemplate(layouts ...string) (*tplimpl.TemplInfo, bool, error) {
dir, d := p.getTemplateBasePathAndDescriptor() dir, d := p.GetInternalTemplateBasePathAndDescriptor()
if len(layouts) > 0 { if len(layouts) > 0 {
d.Layout = layouts[0] d.Layout = layouts[0]

View file

@ -97,7 +97,7 @@ type pageCommon struct {
pageMenus *pageMenus pageMenus *pageMenus
// Internal use // Internal use
page.InternalDependencies page.RelatedDocsHandlerProvider
contentConverterInit sync.Once contentConverterInit sync.Once
contentConverter converter.Converter contentConverter converter.Converter

View file

@ -209,11 +209,11 @@ func (h *HugoSites) doNewPage(m *pageMeta) (*pageState, *paths.Path, error) {
ShortcodeInfoProvider: page.NopPage, ShortcodeInfoProvider: page.NopPage,
LanguageProvider: m.s, LanguageProvider: m.s,
InternalDependencies: m.s, RelatedDocsHandlerProvider: m.s,
init: lazy.New(), init: lazy.New(),
m: m, m: m,
s: m.s, s: m.s,
sWrapped: page.WrapSite(m.s), sWrapped: page.WrapSite(m.s),
}, },
} }

View file

@ -275,7 +275,7 @@ func (pco *pageContentOutput) initRenderHooks() error {
// Inherit the descriptor from the page/current output format. // Inherit the descriptor from the page/current output format.
// This allows for fine-grained control of the template used for // This allows for fine-grained control of the template used for
// rendering of e.g. links. // rendering of e.g. links.
base, layoutDescriptor := pco.po.p.getTemplateBasePathAndDescriptor() base, layoutDescriptor := pco.po.p.GetInternalTemplateBasePathAndDescriptor()
switch tp { switch tp {
case hooks.LinkRendererType: case hooks.LinkRendererType:

View file

@ -397,7 +397,7 @@ func doRenderShortcode(
ofCount[match.D.OutputFormat]++ ofCount[match.D.OutputFormat]++
return true return true
} }
base, layoutDescriptor := po.getTemplateBasePathAndDescriptor() base, layoutDescriptor := po.GetInternalTemplateBasePathAndDescriptor()
q := tplimpl.TemplateQuery{ q := tplimpl.TemplateQuery{
Path: base, Path: base,
Name: sc.name, Name: sc.name,

View file

@ -148,8 +148,8 @@ type InSectionPositioner interface {
PrevInSection() Page PrevInSection() Page
} }
// InternalDependencies is considered an internal interface. // RelatedDocsHandlerProvider is considered an internal interface.
type InternalDependencies interface { type RelatedDocsHandlerProvider interface {
// GetInternalRelatedDocsHandler is for internal use only. // GetInternalRelatedDocsHandler is for internal use only.
GetInternalRelatedDocsHandler() *RelatedDocsHandler GetInternalRelatedDocsHandler() *RelatedDocsHandler
} }

View file

@ -124,7 +124,7 @@ func (p Pages) withInvertedIndex(ctx context.Context, search func(idx *related.I
return nil, nil return nil, nil
} }
d, ok := p[0].(InternalDependencies) d, ok := p[0].(RelatedDocsHandlerProvider)
if !ok { if !ok {
return nil, fmt.Errorf("invalid type %T in related search", p[0]) return nil, fmt.Errorf("invalid type %T in related search", p[0])
} }

View file

@ -97,16 +97,16 @@ func NewStore(opts StoreOptions, siteOpts SiteOptions) (*TemplateStore, error) {
panic("HTML output format not found") panic("HTML output format not found")
} }
s := &TemplateStore{ s := &TemplateStore{
opts: opts, opts: opts,
siteOpts: siteOpts, siteOpts: siteOpts,
optsOrig: opts, optsOrig: opts,
siteOptsOrig: siteOpts, siteOptsOrig: siteOpts,
htmlFormat: html, htmlFormat: html,
storeSite: configureSiteStorage(siteOpts, opts.Watching), storeSite: configureSiteStorage(siteOpts, opts.Watching),
treeMain: doctree.NewSimpleTree[map[nodeKey]*TemplInfo](), treeMain: doctree.NewSimpleTree[map[nodeKey]*TemplInfo](),
treeShortcodes: doctree.NewSimpleTree[map[string]map[TemplateDescriptor]*TemplInfo](), treeShortcodes: doctree.NewSimpleTree[map[string]map[TemplateDescriptor]*TemplInfo](),
templatesByPath: maps.NewCache[string, *TemplInfo](), templatesByPath: maps.NewCache[string, *TemplInfo](),
templateDescriptorByPath: maps.NewCache[string, PathTemplateDescriptor](), cacheLookupPartials: maps.NewCache[string, *TemplInfo](),
// Note that the funcs passed below is just for name validation. // Note that the funcs passed below is just for name validation.
tns: newTemplateNamespace(siteOpts.TemplateFuncs), tns: newTemplateNamespace(siteOpts.TemplateFuncs),
@ -400,10 +400,9 @@ type TemplateStore struct {
siteOpts SiteOptions siteOpts SiteOptions
htmlFormat output.Format htmlFormat output.Format
treeMain *doctree.SimpleTree[map[nodeKey]*TemplInfo] treeMain *doctree.SimpleTree[map[nodeKey]*TemplInfo]
treeShortcodes *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo] treeShortcodes *doctree.SimpleTree[map[string]map[TemplateDescriptor]*TemplInfo]
templatesByPath *maps.Cache[string, *TemplInfo] templatesByPath *maps.Cache[string, *TemplInfo]
templateDescriptorByPath *maps.Cache[string, PathTemplateDescriptor]
dh descriptorHandler dh descriptorHandler
@ -417,6 +416,9 @@ type TemplateStore struct {
// For testing benchmarking. // For testing benchmarking.
optsOrig StoreOptions optsOrig StoreOptions
siteOptsOrig SiteOptions siteOptsOrig SiteOptions
// caches. These need to be refreshed when the templates are refreshed.
cacheLookupPartials *maps.Cache[string, *TemplInfo]
} }
// NewFromOpts creates a new store with the same configuration as the original. // NewFromOpts creates a new store with the same configuration as the original.
@ -540,15 +542,19 @@ func (s *TemplateStore) LookupPagesLayout(q TemplateQuery) *TemplInfo {
} }
func (s *TemplateStore) LookupPartial(pth string) *TemplInfo { func (s *TemplateStore) LookupPartial(pth string) *TemplInfo {
d := s.templateDescriptorFromPath(pth) ti, _ := s.cacheLookupPartials.GetOrCreate(pth, func() (*TemplInfo, error) {
desc := d.Desc d := s.templateDescriptorFromPath(pth)
if desc.Layout != "" { desc := d.Desc
panic("shortcode template descriptor must not have a layout") if desc.Layout != "" {
} panic("shortcode template descriptor must not have a layout")
best := s.getBest() }
defer s.putBest(best) best := s.getBest()
s.findBestMatchGet(s.key(path.Join(containerPartials, d.Path)), CategoryPartial, nil, desc, best) defer s.putBest(best)
return best.templ s.findBestMatchGet(s.key(path.Join(containerPartials, d.Path)), CategoryPartial, nil, desc, best)
return best.templ, nil
})
return ti
} }
func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo { func (s *TemplateStore) LookupShortcode(q TemplateQuery) *TemplInfo {
@ -619,8 +625,14 @@ func (s *TemplateStore) PrintDebug(prefix string, category Category, w io.Writer
}) })
} }
func (s *TemplateStore) clearCaches() {
s.cacheLookupPartials.Reset()
}
// RefreshFiles refreshes this store for the files matching the given predicate. // RefreshFiles refreshes this store for the files matching the given predicate.
func (s *TemplateStore) RefreshFiles(include func(fi hugofs.FileMetaInfo) bool) error { func (s *TemplateStore) RefreshFiles(include func(fi hugofs.FileMetaInfo) bool) error {
s.clearCaches()
if err := s.tns.createPrototypesParse(); err != nil { if err := s.tns.createPrototypesParse(); err != nil {
return err return err
} }
@ -1370,43 +1382,38 @@ type PathTemplateDescriptor struct {
// templateDescriptorFromPath returns a template descriptor from the given path. // templateDescriptorFromPath returns a template descriptor from the given path.
// This is currently used in partial lookups only. // This is currently used in partial lookups only.
func (s *TemplateStore) templateDescriptorFromPath(pth string) PathTemplateDescriptor { func (s *TemplateStore) templateDescriptorFromPath(pth string) PathTemplateDescriptor {
// Check cache first. var (
d, _ := s.templateDescriptorByPath.GetOrCreate(pth, func() (PathTemplateDescriptor, error) { mt media.Type
var ( of output.Format
mt media.Type )
of output.Format
)
// Common cases. // Common cases.
dotCount := strings.Count(pth, ".") dotCount := strings.Count(pth, ".")
if dotCount <= 1 { if dotCount <= 1 {
if dotCount == 0 { if dotCount == 0 {
// Asume HTML. // Asume HTML.
of, mt = s.resolveOutputFormatAndOrMediaType("html", "") of, mt = s.resolveOutputFormatAndOrMediaType("html", "")
} else {
pth = strings.TrimPrefix(pth, "/")
ext := path.Ext(pth)
pth = strings.TrimSuffix(pth, ext)
ext = ext[1:]
of, mt = s.resolveOutputFormatAndOrMediaType("", ext)
}
} else { } else {
path := s.opts.PathParser.Parse(files.ComponentFolderLayouts, pth) pth = strings.TrimPrefix(pth, "/")
pth = path.PathNoIdentifier() ext := path.Ext(pth)
of, mt = s.resolveOutputFormatAndOrMediaType(path.OutputFormat(), path.Ext()) pth = strings.TrimSuffix(pth, ext)
ext = ext[1:]
of, mt = s.resolveOutputFormatAndOrMediaType("", ext)
} }
} else {
path := s.opts.PathParser.Parse(files.ComponentFolderLayouts, pth)
pth = path.PathNoIdentifier()
of, mt = s.resolveOutputFormatAndOrMediaType(path.OutputFormat(), path.Ext())
}
return PathTemplateDescriptor{ return PathTemplateDescriptor{
Path: pth, Path: pth,
Desc: TemplateDescriptor{ Desc: TemplateDescriptor{
OutputFormat: of.Name, OutputFormat: of.Name,
MediaType: mt.Type, MediaType: mt.Type,
IsPlainText: of.IsPlainText, IsPlainText: of.IsPlainText,
}, },
}, nil }
})
return d
} }
// resolveOutputFormatAndOrMediaType resolves the output format and/or media type // resolveOutputFormatAndOrMediaType resolves the output format and/or media type

View file

@ -8,6 +8,7 @@ import (
qt "github.com/frankban/quicktest" qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/hugolib"
"github.com/gohugoio/hugo/resources/kinds" "github.com/gohugoio/hugo/resources/kinds"
"github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/tpl/tplimpl" "github.com/gohugoio/hugo/tpl/tplimpl"
) )
@ -849,7 +850,7 @@ func BenchmarkExecuteWithContext(b *testing.B) {
disableKinds = ["taxonomy", "term", "home"] disableKinds = ["taxonomy", "term", "home"]
-- layouts/all.html -- -- layouts/all.html --
{{ .Title }}| {{ .Title }}|
{{ partial "p1.html" . }} {{ partial "p1.html" . }}
-- layouts/_partials/p1.html -- -- layouts/_partials/p1.html --
p1. p1.
{{ partial "p2.html" . }} {{ partial "p2.html" . }}
@ -878,6 +879,82 @@ p3
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
err := store.ExecuteWithContext(context.Background(), ti, io.Discard, p) err := store.ExecuteWithContext(context.Background(), ti, io.Discard, p)
bb.Assert(err, qt.IsNil) if err != nil {
b.Fatal(err)
}
} }
} }
func BenchmarkLookupPartial(b *testing.B) {
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "home"]
-- layouts/all.html --
{{ .Title }}|
-- layouts/_partials/p1.html --
-- layouts/_partials/p2.html --
-- layouts/_partials/p2.json --
-- layouts/_partials/p3.html --
`
bb := hugolib.Test(b, files)
store := bb.H.TemplateStore
for i := 0; i < b.N; i++ {
fi := store.LookupPartial("p3.html")
if fi == nil {
b.Fatal("not found")
}
}
}
// Implemented by pageOutput.
type getDescriptorProvider interface {
GetInternalTemplateBasePathAndDescriptor() (string, tplimpl.TemplateDescriptor)
}
func BenchmarkLookupShortcode(b *testing.B) {
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "home"]
-- content/toplevelpage.md --
-- content/a/b/c/nested.md --
-- layouts/all.html --
{{ .Title }}|
-- layouts/_shortcodes/s.html --
s1.
-- layouts/_shortcodes/a/b/s.html --
s2.
`
bb := hugolib.Test(b, files)
store := bb.H.TemplateStore
runOne := func(p page.Page) {
pth, desc := p.(getDescriptorProvider).GetInternalTemplateBasePathAndDescriptor()
q := tplimpl.TemplateQuery{
Path: pth,
Name: "s",
Category: tplimpl.CategoryShortcode,
Desc: desc,
}
v := store.LookupShortcode(q)
if v == nil {
b.Fatal("not found")
}
}
b.Run("toplevelpage", func(b *testing.B) {
toplevelpage, _ := bb.H.Sites[0].GetPage("/toplevelpage")
for i := 0; i < b.N; i++ {
runOne(toplevelpage)
}
})
b.Run("nestedpage", func(b *testing.B) {
toplevelpage, _ := bb.H.Sites[0].GetPage("/a/b/c/nested")
for i := 0; i < b.N; i++ {
runOne(toplevelpage)
}
})
}