Skip to content

perf(routing): eliminate unnecessary allocations per request#1585

Open
tkan145 wants to merge 6 commits into
3scale:masterfrom
tkan145:refactor-routing-policy
Open

perf(routing): eliminate unnecessary allocations per request#1585
tkan145 wants to merge 6 commits into
3scale:masterfrom
tkan145:refactor-routing-policy

Conversation

@tkan145
Copy link
Copy Markdown
Contributor

@tkan145 tkan145 commented May 28, 2026

What

Performance refactor to avoid unnecessary allocation per request.

Perf scripts

Details
local root = require('pl.path').currentdir()
package.path = root .. '/?.lua;' ..
               root .. '/gateway/src/?.lua;' ..
               root .. '/gateway/src/apicast/policy/3scale_batcher/?.lua;' ..
               package.path

local ipairs = ipairs
local setmetatable = setmetatable
local match = ngx.re.match
local tostring = tostring

-- Stub ngx.var and ngx.req APIs unavailable outside request context.
-- Liquid template render() calls ngx_variable.available_context() which
-- accesses ngx.var.uri, ngx.req.get_headers(), etc. These must be stubbed
-- before any module that uses them is loaded.
ngx.var = {
  uri = '/api/v1/users',
  path = '/api/v1/users',
  host = 'localhost',
  remote_addr = '127.0.0.1',
  remote_port = '12345',
  scheme = 'http',
  server_addr = '127.0.0.1',
  server_port = '8080',
  request_id = '0',
}

local stub_headers = { ["Content-Type"] = "application/json" }
ngx.req.get_headers = function() return stub_headers end
ngx.req.get_method = function() return "GET" end

local Condition = require('apicast.conditions.condition')
local Operation = require('apicast.conditions.operation')
local TemplateString = require('apicast.template_string')

require('resty.core')
require('benchmark.ips')(function(b)
  b.time = 5
  b.warmup = 2

  -- -------------------------------------------------------
  -- 1. Operation.new per-request vs pre-resolved compare
  -- -------------------------------------------------------

  -- Current: creates Operation.new inside evaluate (per-request)
  local function evaluate_current(left_val, op_str, right_val, right_type, context)
    local op = Operation.new(left_val, 'plain', op_str, right_val, right_type)
    return op:evaluate(context)
  end

  -- Optimized: pre-resolve right template + compare func at init
  local compare_funcs = {
    ['=='] = function(l, r) return tostring(l) == tostring(r) end,
    ['!='] = function(l, r) return tostring(l) ~= tostring(r) end,
    ['matches'] = function(l, r) return (match(tostring(l), tostring(r)) and true) or false end,
  }

  local function make_pre_resolved(op_str, right_val, right_type)
    local right_tpl = TemplateString.new(right_val, right_type or 'plain')
    local cmp = compare_funcs[op_str]
    return function(left_val, context)
      return cmp(left_val, right_tpl:render(context))
    end
  end

  local ctx = {}
  local pre_resolved_eq = make_pre_resolved('==', '/api/v1', 'plain')
  local pre_resolved_match = make_pre_resolved('matches', [[^/api/v\d+]], 'plain')

  b:report('Operation.new per-request (==)', function()
    return evaluate_current('/api/v1', '==', '/api/v1', 'plain', ctx)
  end)

  b:report('pre-resolved compare (==)', function()
    return pre_resolved_eq('/api/v1', ctx)
  end)

  b:report('Operation.new per-request (matches)', function()
    return evaluate_current('/api/v1', 'matches', [[^/api/v\d+]], 'plain', ctx)
  end)

  b:report('pre-resolved compare (matches)', function()
    return pre_resolved_match('/api/v1', ctx)
  end)

  b:compare()

  -- -------------------------------------------------------
  -- 2. TemplateString.new per-request vs cached template
  -- -------------------------------------------------------

  print("\n--- liquid template: parse+render vs cached render ---")

  local liquid_expr = "{{uri}}"
  local liquid_ctx = { uri = "/api/v1/users" }

  -- Current: parse + render every request
  local function liquid_parse_and_render(expr, context)
    return TemplateString.new(expr, "liquid"):render(context)
  end

  -- Optimized: parse once, render per request
  local cached_template = TemplateString.new(liquid_expr, "liquid")
  local function liquid_cached_render(tpl, context)
    return tpl:render(context)
  end

  -- Baseline: plain template (no liquid parsing)
  local plain_template = TemplateString.new("/api/v1/users", "plain")
  local function plain_render(tpl)
    return tpl:render()
  end

  b:report('liquid: parse+render (per-request)', function()
    return liquid_parse_and_render(liquid_expr, liquid_ctx)
  end)

  b:report('liquid: cached render', function()
    return liquid_cached_render(cached_template, liquid_ctx)
  end)

  b:report('plain: render (baseline)', function()
    return plain_render(plain_template)
  end)

  b:compare()

  -- -------------------------------------------------------
  -- 2. Closure allocation: per-request vs module-level func
  -- -------------------------------------------------------

  print("\n--- closure vs module-level function ---")

  local dummy_usage = {
    merge = function() end,
  }

  local dummy_matched_rules = {}

  -- Current: closure created per request
  local function closure_alloc(context)
    context.cleanup = function(self, usage, matched_rules)
      if not self.route_upstream then return end
      local _ = matched_rules
      usage:merge()
    end
  end

  -- Optimized: module-level function assigned once
  local function module_level_cleanup(self, usage, matched_rules)
    if not self.route_upstream then return end
    local _ = matched_rules
    usage:merge()
  end

  local function module_func(context)
    context.cleanup = module_level_cleanup
  end

  local closure_ctx = { route_upstream = nil }

  b:report('per-request closure alloc', function()
    closure_alloc(closure_ctx)
    return closure_ctx.cleanup(closure_ctx, dummy_usage, dummy_matched_rules)
  end)

  b:report('module-level function ref', function()
    module_func(closure_ctx)
    return closure_ctx.cleanup(closure_ctx, dummy_usage, dummy_matched_rules)
  end)

  b:compare()
end)

Perf results

Warming up --------------------------------------
Operation.new per-request (==)

    920530 i/100ms

pre-resolved compare (==)

   3716779 i/100ms

Operation.new per-request (matches)

    120903 i/100ms

pre-resolved compare (matches)

    138079 i/100ms

liquid: parse+render (per-request)

     16728 i/100ms

liquid: cached render

     69192 i/100ms

plain: render (baseline)

    216056 i/100ms

per-request closure alloc

    195534 i/100ms

module-level function ref

    219908 i/100ms

Calculating -------------------------------------
                     Operation.new per-request (==) 11268321.1 (±0.6%) i/s -   57072860 in   5.065098s
                          pre-resolved compare (==) 83650585.7 (±1.0%) i/s -  419996027 in   5.021378s
                Operation.new per-request (matches)  1174708.5 (±2.6%) i/s -    5924247 in   5.046572s
                    pre-resolved compare (matches)  1223650.6 (±13.3%) i/s -    6075476 in   5.051734s
                 liquid: parse+render (per-request)   142548.8 (±4.3%) i/s -     719304 in   5.056303s
                              liquid: cached render   588118.2 (±6.8%) i/s -    2975256 in   5.084045s
                           plain: render (baseline) 77910469.3 (±3.0%) i/s -  389332912 in   5.001722s
                          per-request closure alloc 18210146.5 (±3.6%) i/s -   91118844 in   5.010522s
                          module-level function ref 87281446.6 (±1.8%) i/s -  436297472 in   5.000413s

Conclusion

  • Pre-allocate template string is about 8 times faster than allocate it per request
  • Matches operation is slow
  • Liquid render is a lot slower than plain string render

Previously, TemplateString.new is called per-request inside the closure.
The liquid template is static so we only need to parse it once at init time.
@tkan145 tkan145 requested a review from a team as a code owner May 28, 2026 23:57
tkan145 added 3 commits June 1, 2026 11:17
Previously, a new Operation object was allocated for each request. The
value on the right is static, so we can allocate that value beforehand
in the constructor, while the value on the left is always evaluated as
a regular string, so we just pass it in as is and avoid allocating
another TemplateString.
@tkan145 tkan145 force-pushed the refactor-routing-policy branch from ccb1c9e to ca48ae1 Compare June 1, 2026 01:20
@tkan145 tkan145 changed the title Refactor routing policy perf(routing): eliminate unnecessary TemplateString allocation per reque Jun 1, 2026
@tkan145 tkan145 changed the title perf(routing): eliminate unnecessary TemplateString allocation per reque perf(routing): eliminate unnecessary TemplateString allocation per request Jun 1, 2026
@tkan145 tkan145 changed the title perf(routing): eliminate unnecessary TemplateString allocation per request perf(routing): eliminate unnecessary allocations per request Jun 1, 2026
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.

1 participant