Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Performance degradation when linting large Lua files with luacheck v0.26+ #104

Open
tomlau10 opened this issue Jan 26, 2024 · 3 comments · May be fixed by #105
Open

Performance degradation when linting large Lua files with luacheck v0.26+ #104

tomlau10 opened this issue Jan 26, 2024 · 3 comments · May be fixed by #105
Labels
help wanted Extra attention is needed

Comments

@tomlau10
Copy link

TL;DR

The introduction of the is_circular_reference function call within the for loop of stages/resolve_locals.lua since version v0.26 seems to significantly impact performance as observed in this commit: a24332e

The long story

Greetings, I've been utilizing luacheck v0.25 via luarocks in a private project for an extended period. Within this project, there exists a sizable Lua file (>100K lines) primarily comprising data tables (~95%) interspersed with logic code (~5K lines). Under version v0.25, linting this file takes approximately 10 seconds, which is acceptable given its substantial size.

However, subsequent to updating to the latest release, luacheck v1.1.2, the linting process for this file consumes over 10 minutes. Further investigation revealed that this performance regression commenced with v0.26.

Upon analyzing the differences between v0.25 and v0.26, I attempted to revert the alterations near the aforementioned for loop. Consequently, the linting time returned to normal, averaging around ~10 seconds.

How to reproduce

Given the private nature of the project, sharing the actual file is not feasible. Instead, I will outline the file structure and provide a minimal test file capable of reproducing the issue.

File Structure:

-- Standard module require
local abc = require "abc"
...

-- Approximately 200 local function definitions used by the data table below
local function handlerA(...)
    ...
end

-- Over 5000 data templates formatted as { [id] = <template> }
local DATA = {
    [id] = {
        -- Approximately 20 attribute fields for each data template
        attr1 = 1,
        attr2 = "2",
        attr3 = true,
        ...
        -- Not all templates have `on_use`; roughly 50% of templates include it
        on_use = function (self)
            -- This section references the local functions defined above
            return handlerA(...)
        end,
    },
    ...
}
return DATA

Observations

  • Removing the entire local DATA = {...} portion, leaving only the local function definitions (~5K lines), results in a lint time of ~1 second for both luacheck versions.
  • Conversely, removing the entire local function definitions, leaving only the local DATA = {...} (~95K lines), yields a lint time of ~8 seconds for both luacheck versions.
  • Combining both parts causes luacheck v0.26 to take 10+ minutes.
  • Additionally, removing all on_use functions from the data templates, such that none of them have upvalues pointing above, leads to a lint time of ~1 minute for luacheck v0.26.

Minimal Reproducing File

I have crafted a gen_test.lua script to generate a test.lua file in the aforementioned format:

local NUM_FUNCS = 50    -- control num handlers to be generated
local NUM_DATAS = 200   -- control data templates to be generated
local NUM_ATTRS = 10    -- control attributes count of each data template

local f = io.open("test.lua", "w")

-- gen handlers
for i = 1, NUM_FUNCS do
    f:write(("local function handler%03d(...) return ... end\n"):format(i))
end

-- gen datas
f:write("local DATA = {\n")
for id = 1, NUM_DATAS do
    f:write(("    [%d] = {\n"):format(id+10000))
    for attr = 1, NUM_ATTRS do
        f:write(("        attr%02d = 0,\n"):format(attr))
    end
    f:write(("        on_use = function (...) return handler%03d(...) end,\n"):format(
        NUM_FUNCS > 0
            and id%NUM_FUNCS + 1
            or 0
    ))
    f:write("    },\n")
end
f:write("}\n")
f:write("return DATA\n")

On my MacBook Pro 2016, v0.25 takes less than 1 second while v0.26 takes around 30 seconds.

Thoughts

I acknowledge that this file structure may be unconventional, but refactoring is challenging within the project. I aim to lint this file given the presence of logic code in the upper portion and the inline on_use functions in the data templates. Are there alternative suggestions on how to work around or disable the newly added circular reference check? Could the is_circular_reference() function be optimized, perhaps by caching the result of a checked node?

Your assistance in addressing this performance concern would be greatly appreciated.

@alerque
Copy link
Member

alerque commented Jan 26, 2024

Contributions welcome. I've never seen a speed regression on this scale, but obviously we'd be happy to see it fixed. I probably won't have time to work on it for a while, but perhaps you or anybody else that comes along can come up with a solution. I'll be happy to facilitate contributions and getting them released.

Two approaches I can think of to look into:

  1. If the answer to some question is just being recalculated from the same inputs over and over, it might work to just add memoization to the function.
  2. If that isn't possible perhaps we can add a lint id and gate the check behind it such that it can be ignored for this particular case while still getting the benefit of other linting.

@alerque alerque added the help wanted Extra attention is needed label Jan 26, 2024
@tomlau10
Copy link
Author

  1. If the answer to some question is just being recalculated from the same inputs over and over, it might work to just add memoization to the function.

It looks like a solid approach! After reviewing the code for contains_call, it seems that caching the result into node.is_contains_call before the function returns could be a viable solution. To elaborate, the function would return the value of node.is_contains_call at the beginning if it is not nil.

Perhaps something like this:

local function contains_call(node)
   if node.is_contains_call ~= nil then
      -- return cached result
      return node.is_contains_call
   end

   node.is_contains_call = false

   if node.tag == "Call" or node.tag == "Invoke" then
      node.is_contains_call = true
   elseif node.tag ~= "Function" then
      for _, sub_node in ipairs(node) do
         if type(sub_node) == 'table' and contains_call(sub_node) then
            node.is_contains_call = true
            break
         end
      end
   end

   return node.is_contains_call
end

I might test it next week if I have time and open a pull request after testing 😄

@tomlau10
Copy link
Author

I've just tested the above changes on latest version of luacheck locally and confirmed that it now has the same performance as v0.25. I'll soon open a PR on it. 🎉

@tomlau10 tomlau10 linked a pull request Jan 30, 2024 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed
Development

Successfully merging a pull request may close this issue.

2 participants