Skip to content

Compile each PS1 template once instead of on every render - #21

Merged
tpowell-progress merged 1 commit into
chef:mainfrom
tas50:perf/cache-compiled-templates
Sep 18, 2026
Merged

tpowell-progress merged 1 commit into
chef:mainfrom
tas50:perf/cache-compiled-templates

Conversation

@tas50

@tas50 tas50 commented Aug 28, 2026

Copy link
Copy Markdown

Summary

Scripts.render reads the .ps1.erb file off disk and constructs a fresh Erubi::Engine on every call:

template_path = File.expand_path("#{File.dirname(__FILE__)}/#{template}.ps1.erb")
template = File.read(template_path)
...
b.eval(Erubi::Engine.new(template).src)

The templates ship inside the gem and cannot change while it is loaded, so the read and the compile produce an identical result each time.

Every remote operation goes through here — exists?, create_dir, delete, checksum, check_files and extract_files render once each, and download renders once per chunk, so a large transfer re-reads and recompiles download.ps1.erb thousands of times.

The compiled Erubi source is now memoized per template name. The eval still happens per call, since that is what binds the caller's locals — only the file read and the compile are hoisted out.

Results

200 renders of exists.ps1.erb, counting File.read calls:
  main    200
  branch    1

5000 renders of download.ps1.erb, median of 5 runs, separate processes
(Ruby 4.0.6, macOS arm64):
  main    403.9 ms   (80.8 us each)
  branch  136.2 ms   (27.2 us each)   2.97x faster

On magnitude, so you can judge whether this is worth carrying: 54 µs per call is nothing next to a WinRM round-trip, so this will not show up as a faster transfer. The argument for it is the eliminated filesystem read on every single remote operation and the per-call Erubi::Engine allocation, not wall-clock on the wire. If you would rather not carry a module-level cache for that, closing this is a perfectly reasonable call — the other performance PRs stand on their own.

Verification

All seven templates render byte-identically to main, checked by SHA256 of the output, with contexts exercising spaces, PowerShell $env: variables, non-ASCII paths and multi-line hash tables:

5c522500123f998f  check_files    1509B
ff6fe6be09c91b93  checksum        488B
a5f9255b26d73c74  create_dir      210B
4f05e3180abcb665  delete          177B
f1de9eb9ad19de97  download        535B
bf8d8f0b90db4cda  exists          167B
b3ef6102c02a5a82  extract_files  1380B
711a210e22e07ced  exists(Binding) 176B
ArgumentError raised for unsupported context: ok

Identical on both sides. Each template is also rendered twice per run to confirm the cache is not mutated between calls, and the Binding and NilClass context branches are unchanged.

bundle exec rspec spec/unit                          # 3 examples, 0 failures
bundle exec cookstyle --chefstyle -c .rubocop.yml    # 16 files, no offenses

One note: COMPILED[template] ||= ... is not guarded by a mutex. Under MRI a concurrent race would at worst compile the same template twice and store the same value, so it is benign, but flagging it in case you would prefer it locked.

Scripts.render reads the .ps1.erb off disk and builds a fresh Erubi::Engine
every time it is called. The templates ship inside the gem and cannot change
while it is loaded, so all of that repeats identically on each call.

Every remote operation goes through here -- exists?, create_dir, delete,
checksum, check_files and extract_files render once each, and download renders
once per chunk, so a large transfer re-reads and recompiles download.ps1.erb
thousands of times.

The compiled Erubi source is now memoized per template name. The eval still
happens per call, since that is what binds the caller's locals; only the file
read and the compile are hoisted.

    200 renders of exists.ps1.erb:
      File.read calls   main 200   branch 1

    5000 renders of download.ps1.erb, median of 5 (Ruby 4.0.6, macOS arm64):
      main    403.9 ms   (80.8 us each)
      branch  136.2 ms   (27.2 us each)   2.97x faster

All seven templates verified byte-identical, along with the Binding context
form and the ArgumentError raised for an unsupported context type.

Signed-off-by: Tim Smith <tsmith84@proton.me>
@tpowell-progress
tpowell-progress merged commit 249f939 into chef:main Sep 18, 2026
35 checks passed
@tas50
tas50 deleted the perf/cache-compiled-templates branch September 18, 2026 15:24
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