Phrogz
Phrogz

Reputation: 303178

Load Lua function chunk, modify environment in 5.2 with sandbox

This question is similar to Modify Lua Chunk Environment, but with a twist.

I want to load strings as reusable Lua functions, where they all share the same sandbox for evaluation, but where each invocation I can pass in different values for the variables.

For example, say I want to evaluate the equation round(10*w/h) + a * b, with round, w, and h all values in a common sandbox environment, but where a and b are values that I want to modify each time I evaluate the equation.

This question shows how to dynamically change the environment for a compiled function. However, it does so setting the ENTIRE environment for the function, without the fallback sandbox that I have.

What's an efficient way to achieve my goals? Note that I almost only care about time needed to evaluate the function, not the setup time.


The way I have things currently, users write CSS expressions like:

box {
   left: @x;
   width: viewwidth - @w;
}

...where @x and @w are attributes of the box element (the 'local' variables) and viewwidth is a sheet-level variable set up elsewhere (my sandbox). Each of the property values—the portion after the :—is parsed out as a string to be compiled into a Lua function. They use normal Lua syntax, except where I currently swap the @ with _el. in order to dereference the element table.

For answers to this question it is acceptable to keep this same syntax and require a differentiation between the local and sheet variables, BUT it is also acceptable to have a solution the gets rid of the @ symbols and treats all variables the same.

Upvotes: 3

Views: 1212

Answers (1)

Phrogz
Phrogz

Reputation: 303178

I came up with 6 techniques for accomplishing the goal. The bottom of this post benchmarks their performance for evaluation speed. The fastest techniques require differentiating the local variables from sandbox variables in code:

local SANDBOX = {
  w=1920,
  h=1080,
  round=function(n) return math.floor(n+0.5) end
}
local CODE = "round(10*w/h) + @a * @b"

function compile(code, sandbox)
  local munged = 'local _el=... return '..code:gsub('@', '_el.')
  return load(munged, nil, nil, sandbox)
end

local f = compile(CODE, SANDBOX)
print(f{a=1, b=2}) --> 20
print(f{a=3, b=4}) --> 30

If you don't want to differentiate the changing variables from those in the sandbox, or don't want to use a fragile sub() like the above, the next fastest is to mutate the __index of your locals and then pass it as the environment. You can wrap this in a helper function to make it easier:

local CODE = "round(10*w/h) + a * b"

function compile(code, sandbox)
  local meta = {__index=sandbox}
  return {meta=meta, f=load('_ENV=... return '..code)}
end

function eval(block, locals)
  return block.f(setmetatable(locals, block.meta))
end

local f = compile(CODE, SANDBOX)
print(eval(f, {a=1, b=2})) --> 20
print(eval(f, {a=3, b=4})) --> 30

Here is the benchmark results of all the techniques below. Note that the fastest one can be even faster because unlike all other techniques the compile function returns a function that can be invoked directly, instead of wrapped in an evaluation helper script:

scope as a table, 2              : 0.9s (0.22µs per eval)
scope as a table                 : 1.1s (0.27µs per eval)
Use __ENV, change scope meta     : 1.3s (0.32µs per eval)
blank env, change meta of scope  : 1.6s (0.41µs per eval)
copy values over environment     : 2.8s (0.70µs per eval)
setfenv, change scope meta       : 3.0s (0.74µs per eval)
local SANDBOX = {
    w     = 1920,
    h     = 1080,
    round = function(n) return math.floor(n+0.5) end
}
local TESTS = {
    {env={a=1, b=2}, expected=18+2},
    {env={a=4, b=3}, expected=18+12},
    {env={a=9, b=7}, expected=18+63},
    {env={a=4, b=5}, expected=18+20},
}

-- https://leafo.net/guides/setfenv-in-lua52-and-above.html
local function setfenv(fn, env)
    local i = 1
    while true do
        local name = debug.getupvalue(fn, i)
        if name == "_ENV" then
            debug.upvaluejoin(fn, i, (function() return env end), 1)
            break
        elseif not name then
            break
        end
        i = i + 1
    end
    return fn
end

local techniques = {
    ["copy values over environment"]={
        code="round(10*w/h) + a*b",
        setup=function(code, fallback)
            local env = setmetatable({},{__index=fallback})
            return {env=env,func=load("return "..code,nil,nil,env)}
        end,
        call=function(block, kvs)
            for k,v in pairs(block.env) do block.env[k]=nil end
            for k,v in pairs(kvs) do block.env[k]=v end
            return block.func()
        end
    },
    ["blank env, change meta of scope"]={
        code="round(10*w/h) + a*b",
        setup=function(code, fallback)
            local kvsmeta = {__index=fallback}
            local envmeta = {}
            local env = setmetatable({},envmeta)
            return {envmeta=envmeta,meta=meta,kvsmeta=kvsmeta,func=load("return "..code,nil,nil,env)}
        end,
        call=function(block, kvs)
            block.envmeta.__index=kvs
            setmetatable(kvs, block.kvsmeta)
            return block.func()
        end
    },
    ["setfenv, change scope meta"]={
        code="round(10*w/h) + a*b",
        setup=function(code, fallback)
            return {meta={__index=fallback}, func=load("return "..code)}
        end,
        call=function(block, kvs)
            setmetatable(kvs,block.meta)
            setfenv(block.func, kvs)
            return block.func()
        end
    },
    ["Use __ENV, change scope meta"]={
        code="round(10*w/h) + a*b",
        setup=function(code, fallback)
            local meta = {__index=fallback}
            return {meta=meta, func=load("_ENV=... return "..code)}
        end,
        call=function(block, kvs)
            return block.func(setmetatable(kvs, block.meta))
        end
    },
    ["scope as a table"]={
        -- NOTE: requires different code than all other techniques!
        code="round(10*w/h) + _el.a * _el.b",
        setup=function(code, fallback)
            local env = setmetatable({},{__index=fallback})
            return {env=env,func=load("return "..code,nil,nil,env)}
        end,
        call=function(block, kvs)
            block.env._el=kvs
            return block.func()
        end
    },
    ["scope as a table, 2"]={
        -- NOTE: requires different code than all other techniques!
        code="round(10*w/h) + _el.a * _el.b",
        setup=function(code, fallback)
            return load("local _el=... return "..code,nil,nil,fallback)
        end,
        call=function(func, kvs)
            return func(kvs)
        end
    },
}

function validate()
    for name,technique in pairs(techniques) do
        local f = technique.setup(technique.code, SANDBOX)
        for i,test in ipairs(TESTS) do
            local actual = technique.call(f, test.env)
            if actual~=test.expected then
                local err = ("%s test #%d expected %d but got %s\n"):format(name, i, test.expected, tostring(actual))
                io.stderr:write(err)
                error(-1)
            end
        end
    end
end

local N = 1e6
function benchmark(setup, call)
    for name,technique in pairs(techniques) do
        local f = technique.setup(technique.code, SANDBOX)
        local start = os.clock()
        for i=1,N do
            for i,test in ipairs(TESTS) do
                technique.call(f, test.env)
            end
        end
        local elapsed = os.clock() - start
        print(("%-33s: %.1fs (%.2fµs per eval)"):format(name, elapsed, 1e6*elapsed/#TESTS/N))
    end
end

validate()
benchmark()

Upvotes: 1

Related Questions