Performance Tuning

View Source

Optimize Mau template compilation and rendering for maximum performance.

Overview

This guide covers performance optimization techniques for Mau templates, from compilation to rendering and filter usage.

Template Compilation

Compile Once, Render Many

The most important optimization: compile templates once and reuse the AST.

# ❌ Bad: Compiles on every render (expensive)
defmodule MyApp.BadExample do
  def render_user(user_data) do
    template = "User: {{ name }}, Email: {{ email }}"
    {:ok, output} = Mau.render(template, user_data)
    output
  end
end

# ✅ Good: Compile once at startup
defmodule MyApp.GoodExample do
  @user_template """
  User: {{ name }}, Email: {{ email }}
  """

  @compiled_template elem(Mau.compile(@user_template), 1)

  def render_user(user_data) do
    {:ok, output} = Mau.render(@compiled_template, user_data)
    output
  end
end

Pre-compile in Application Init

For applications with many templates, pre-compile during startup:

defmodule MyApp.Templates do
  @moduledoc """
  Pre-compiled templates for the application.
  """

  # Compile all templates at startup
  def init_templates do
    %{
      user_card: compile_template("user_card.html"),
      email_welcome: compile_template("email_welcome.html"),
      report_summary: compile_template("report_summary.txt")
    }
  end

  defp compile_template(filename) do
    content = File.read!(Path.join(["templates", filename]))
    {:ok, ast} = Mau.compile(content)
    ast
  end
end

# Usage in application startup
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    # Pre-compile all templates
    templates = MyApp.Templates.init_templates()
    Application.put_env(:my_app, :compiled_templates, templates)

    # ... rest of startup
  end
end

Cache Compiled Templates

Store compiled templates in ETS for fast access:

defmodule MyApp.TemplateCache do
  @cache_table :template_cache

  def init do
    :ets.new(@cache_table, [:named_table, :public, :set])
  end

  def get_or_compile(name, template_string) do
    case :ets.lookup(@cache_table, name) do
      [{^name, ast}] ->
        {:ok, ast}

      [] ->
        case Mau.compile(template_string) do
          {:ok, ast} ->
            :ets.insert(@cache_table, {name, ast})
            {:ok, ast}

          error ->
            error
        end
    end
  end

  def clear do
    :ets.delete_all_objects(@cache_table)
  end
end

Rendering Optimization

Use Type Preservation Wisely

Type preservation adds overhead - use only when needed:

# ❌ Unnecessary type preservation
{:ok, output} = Mau.render("Count: {{ items | length }}", context, preserve_types: true)
# Result: "Count: 3" (string anyway)

# ✅ Smart type preservation
{:ok, result} = Mau.render("{{ total }}", context, preserve_types: true)
# Result: 1500 (number, no string conversion)

Set Appropriate Loop Limits

Prevent runaway loops with realistic limits:

# Dangerous: User could create infinite-like loops
{:ok, output} = Mau.render(user_template, context)

# Safe: Limit iterations
{:ok, output} = Mau.render(
  user_template,
  context,
  max_loop_iterations: 5000  # Reasonable limit for most cases
)

Batch Rendering

For multiple templates with same context, batch them:

# ❌ Inefficient: Processes context separately
results =
  Enum.map(templates, fn template ->
    {:ok, output} = Mau.render(template, context)
    output
  end)

# ✅ Efficient: Prepare context once
prepared_context = prepare_context(raw_context)

results =
  Enum.map(templates, fn template ->
    {:ok, output} = Mau.render(template, prepared_context)
    output
  end)

defp prepare_context(raw_context) do
  %{
    "name" => String.downcase(raw_context.name),
    "items" => Enum.sort(raw_context.items),
    "totals" => calculate_totals(raw_context)
  }
end

Filter Performance

Use Built-in Filters

Built-in filters are optimized in Elixir:

# ❌ Manual looping (slower)
def custom_filter(items, _args) do
  result = []
  for item <- items do
    result = [item | result]
  end
  {:ok, Enum.reverse(result)}
end

# ✅ Use Enum (optimized)
def custom_filter(items, _args) do
  {:ok, Enum.reverse(items)}
end

Chain Filters Efficiently

Order filters for best performance:

# ❌ Processes large list multiple times
{{ items | sort | reverse | first }}

# ✅ Filter before sort (smaller dataset)
{{ items | filter("status", "active") | sort | reverse | first }}

Avoid N+1 Filter Problems

# ❌ Creates 1 lookup per item (N+1)
{% for item in items %}
  {{ item | lookup_price(prices) }}
{% endfor %}

# ✅ Preprocess lookups before template
{:ok, enriched_items} = Mau.render_map(%{
  "#map" => ["{{$items}}", %{
    "id" => "{{$loop.item.id}}",
    "price" => "{{$self.prices[$loop.item.id]}}"
  }]
}, %{
  "$items" => items,
  "$self" => %{"prices" => prices_map}
})

Context Optimization

Keep Context Minimal

Only include data that templates need:

# ❌ Large context with unused data
context = %{
  "user" => all_user_data,           # 50+ fields
  "items" => all_items,              # 10,000+ items
  "settings" => all_settings         # 100+ fields
}

# ✅ Minimal context with only needed data
context = %{
  "user" => %{
    "name" => user.name,
    "email" => user.email
  },
  "items" => Enum.filter(all_items, &(&1.visible)),
  "settings" => %{
    "theme" => settings.theme
  }
}

Preprocess Complex Data

Transform data before passing to templates:

# ❌ Let template do all the work
context = %{
  "orders" => raw_orders
}
# Template processes all orders

# ✅ Preprocess in application code
context = %{
  "orders" => Enum.map(raw_orders, fn order ->
    %{
      "id" => order.id,
      "total" => order.total,
      "formatted_total" => format_currency(order.total),
      "status" => status_label(order.status)
    }
  end)
}
# Template just displays preprocessed data

Use Lazy Evaluation

For large datasets, compute only when needed:

# ❌ Evaluates all summaries upfront
context = %{
  "monthly_summaries" => Enum.map(1..12, &calculate_month_summary/1)
}

# ✅ Compute summaries in template only if used
context = %{
  "months" => 1..12,
  "calculate_summary" => &calculate_month_summary/1
}

Map Directives Optimization

Use #filter Before #map

Filter collections before transforming:

# ❌ Maps everything then filters
input = %{
  "results" => %{
    "#map" => [
      "{{$items}}",
      %{"id" => "{{$loop.item.id}}"}
    ]
  },
  "active_only" => %{
    "#filter" => ["{{results}}", "{{$loop.item.status == 'active'}}"]
  }
}

# ✅ Filters first, then maps
input = %{
  "active_results" => %{
    "#pipe" => [
      "{{$items}}",
      [
        %{"#filter" => "{{$loop.item.status == 'active'}}"},
        %{"#map" => %{"id" => "{{$loop.item.id}}"}}
      ]
    ]
  }
}

Avoid Nested #map with Complex Logic

# ❌ Complex nested logic
%{
  "#map" => [
    "{{$data}}",
    %{
      "items" => %{
        "#map" => [
          "{{$loop.item.children}}",
          %{
            "status" => %{
              "#if" => ["{{$loop.item.status}}", ...]
            }
          }
        ]
      }
    }
  ]
}

# ✅ Preprocess in application
preprocessed = Enum.map(data, fn item ->
  %{
    "items" => Enum.map(item.children, fn child ->
      %{"status" => compute_status(child)}
    end)
  }
end)

{:ok, result} = Mau.render_map(%{
  "items" => "{{$items}}"
}, %{"$items" => preprocessed})

Benchmarking

Measure Performance

Use :timer.tc for benchmarking:

defmodule MyApp.Benchmarks do
  def benchmark_template do
    template = "Hello {{ name }}, you have {{ count }} items"
    context = %{"name" => "Alice", "count" => 42}

    # Warm up
    Mau.render(template, context)

    # Measure
    {time_us, {:ok, _output}} = :timer.tc(Mau, :render, [template, context])
    time_ms = time_us / 1000

    IO.puts("Rendered in #{time_ms} ms")
  end

  def benchmark_filter do
    {time_us, result} = :timer.tc(fn ->
      Mau.FilterRegistry.apply("upper_case", "hello world", [])
    end)

    IO.puts("Filter took #{time_us / 1000} ms")
  end
end

Use Benchee for Comprehensive Testing

defmodule MyApp.BenchmarksWithBenchee do
  def run do
    Benchee.run(%{
      "simple_render" => fn ->
        {:ok, _} = Mau.render("{{ name }}", %{"name" => "Alice"})
      end,
      "complex_render" => fn ->
        {:ok, _} = Mau.render(complex_template(), complex_context())
      end,
      "precompiled_render" => fn ->
        {:ok, _} = Mau.render(precompiled_ast(), complex_context())
      end
    },
      time: 10,
      memory_time: 2
    )
  end
end

Common Performance Issues

Issue: Slow Template Rendering

Symptoms: Templates take seconds to render

Causes:

  • Large datasets
  • N+1 lookups in filters
  • Unoptimized filters

Solutions:

# 1. Profile with :fprof
:fprof.start()
Mau.render(template, context)
:fprof.stop()

# 2. Use simpler templates for large datasets
# 3. Preprocess data in application

# 4. Add loop limits
Mau.render(template, context, max_loop_iterations: 5000)

Issue: Memory Usage Growing

Symptoms: Application memory keeps increasing

Causes:

  • Compiled templates not cached properly
  • Unbounded context growth
  • Large template strings

Solutions:

# 1. Use template cache
MyApp.TemplateCache.get_or_compile("my_template", template_source)

# 2. Clear old compiled templates periodically
:ets.delete_all_objects(:template_cache)

# 3. Use streaming for large contexts
Enum.each(large_dataset, fn item ->
  context = %{"item" => item}
  {:ok, output} = Mau.render(template, context)
  IO.write(output)
end)

Issue: Slow Filter Chains

Symptoms: Chained filters slow down template rendering

Causes:

  • Multiple passes over data
  • Inefficient filter order

Solutions:

# ❌ Slow: Multiple passes
{{ items | sort | reverse | map("name") | join(", ") }}

# ✅ Fast: Preprocess
preprocessed = items
  |> Enum.sort()
  |> Enum.reverse()
  |> Enum.map(&(&1["name"]))
  |> Enum.join(", ")

{{ preprocessed }}

Caching Strategies

Fragment Caching

Cache rendered fragments:

defmodule MyApp.FragmentCache do
  @cache_table :fragment_cache

  def init do
    :ets.new(@cache_table, [:named_table, :public, :set])
  end

  def render_cached(key, template, context, ttl_seconds \\ 3600) do
    case :ets.lookup(@cache_table, key) do
      [{^key, output, expiry}] ->
        if System.os_time(:second) < expiry do
          output
        else
          :ets.delete(@cache_table, key)
          render_and_cache(key, template, context, ttl_seconds)
        end

      [] ->
        render_and_cache(key, template, context, ttl_seconds)
    end
  end

  defp render_and_cache(key, template, context, ttl) do
    {:ok, output} = Mau.render(template, context)
    expiry = System.os_time(:second) + ttl
    :ets.insert(@cache_table, {key, output, expiry})
    output
  end
end

Best Practices Summary

  1. Compile once, render many times
  2. Cache compiled templates
  3. Preprocess complex data
  4. Use type preservation selectively
  5. Set reasonable loop limits
  6. Filter before transformation
  7. Keep context minimal
  8. Profile and benchmark
  9. Batch operations
  10. Monitor memory usage

See Also