From 7bf9ab631d34ba683345fa4ba3b7ac0bc1f37360 Mon Sep 17 00:00:00 2001 From: Christian Fillion Date: Wed, 1 May 2024 18:40:20 -0400 Subject: [PATCH] Release Lua profiler v1.1 (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add an optional child_flags parameter to profiler.showProfile() • Add profiler.runloop (alias of profiler.defer) • Add a flame graph view mode (enable in the Profile menu) • Fix view mode and sort options not applying to inactive profiles • Highlight the entire row on hover in the tree view mode • Rename profiler.reset() to .clear() • Report usage errors at the user's call site --- Development/cfillion_Lua profiler.lua | 636 +++++++++++++++++++------- 1 file changed, 462 insertions(+), 174 deletions(-) diff --git a/Development/cfillion_Lua profiler.lua b/Development/cfillion_Lua profiler.lua index 84ae8cbc6..69274fc48 100644 --- a/Development/cfillion_Lua profiler.lua +++ b/Development/cfillion_Lua profiler.lua @@ -1,9 +1,19 @@ -- @description Lua profiler -- @author cfillion --- @version 1.0 +-- @version 1.1 +-- @changelog +-- • Add an optional child_flags parameter to profiler.showProfile() +-- • Add profiler.runloop (alias of profiler.defer) +-- • Add a flame graph view mode (enable in the Profile menu) +-- • Fix view mode and sort options not applying to inactive profiles +-- • Highlight the entire row on hover in the tree view mode +-- • Rename profiler.reset() to .clear() +-- • Report usage errors at the user's call site -- @provides [nomain] . -- @link Forum thread https://forum.cockos.com/showthread.php?t=283461 --- @screenshot https://i.imgur.com/cCe6fBB.png +-- @screenshot +-- https://i.imgur.com/cCe6fBB.png +-- https://i.imgur.com/FjbZ6VB.gif -- @donation https://reapack.com/donate -- @about -- # Lua profiler @@ -11,37 +21,42 @@ -- This is a library for helping the development of Lua ReaScript. See the [forum thread](https://forum.cockos.com/showthread.php?t=283461) for usage information. A summary of the provided API is at the top of the source code. -- Public API summary: +-- +-- local profiler = dofile(reaper.GetResourcePath() .. +-- '/Scripts/ReaTeam Scripts/Development/cfillion_Lua profiler.lua') +-- +-- # General +-- * profiler.clear() +-- * profiler.defer(function callback) and alias profiler.runloop +-- * profiler.run() +-- +-- # Instrumentation (see `makeAttachOpts` for supported options) -- * profiler.attachTo(string var, nil|table opts) -- * profiler.detachFrom(string var, nil|table opts) -- * profiler.attachToLocals(nil|table opts) -- * profiler.detachFromLocals(nil|table opts) -- * profiler.attachToWorld() -- * profiler.detachFromWorld() --- * profiler.reset() +-- +-- # Acquisition -- * profiler.start() +-- * profiler.stop() +-- * nil|bool|integer profiler.auto_start +-- +-- # Measurement -- * profiler.enter(string what) -- * profiler.leave() --- * profiler.stop() -- * profiler.frame() --- * profiler.showWindow(ImGui_Context* ctx, nil|bool p_open, nil|integer flags) --- * profiler.showProfile(ImGui_Context* ctx, string label, nil|number width, nil|number height) --- * profiler.defer(function callback) --- * profiler.run() --- * profiler.reset() -- --- * nil|bool|integer profiler.auto_start +-- # Embedding +-- * profiler.showWindow(ImGui_Context* ctx, nil|bool p_open, nil|integer window_flags) +-- * profiler.showProfile(ImGui_Context* ctx, string label, nil|number width, nil|number height, nil|integer child_flags) local ImGui = (function() local host_reaper = reaper reaper = {} for k,v in pairs(host_reaper) do reaper[k] = v end - dofile(reaper.GetResourcePath() .. - '/Scripts/ReaTeam Extensions/API/imgui.lua')('0.8.7') - local ImGui = {} - for name, func in pairs(reaper) do - name = name:match('^ImGui_(.+)$') - if name then ImGui[name] = func end - end + local ImGui = dofile(reaper.ImGui_GetBuiltinPath() .. '/imgui.lua') '0.9' reaper = host_reaper return ImGui end)() @@ -52,13 +67,13 @@ local PROFILES_SIZE = 8 -- 'active' is not in 'state' for faster access local profiler, profiles, active, state = {}, {}, false, { - current = 1, auto_active = 0, sort = {}, + current = 1, auto_active = 0, sort = {}, zoom = 1, } local attachments, wrappers, locations = {}, {}, {} local getTime = reaper.time_precise -- faster than os.clock local profile, profile_cur -- references to profiles[current] for quick access local options, options_cache, options_default = {}, {}, { - tree_view = true, + view = 1, -- tree view } -- weak references to have the garbage collector auto-clear the caches @@ -97,7 +112,7 @@ setmetatable(options, { local v = options_cache[key] if v ~= nil then return v end v = options_default[key] - assert(v ~= nil, 'option does not exist') + if v == nil then error('option does not exist', 2) end if not reaper.HasExtState(EXT_STATE_ROOT, key) then reaper.SetExtState(EXT_STATE_ROOT, key, tostring(v), true) else @@ -105,15 +120,19 @@ setmetatable(options, { v = reaper.GetExtState(EXT_STATE_ROOT, key) if t == 'boolean' then v = v == 'true' + elseif t == 'number' then + v = tonumber(v) or 0 elseif t ~= 'string' then - error('unsupported type') + error('unsupported type', 2) end end options_cache[key] = v return v end, __newindex = function(options, key, value) - assert(type(value) == type(options_default[key]), 'unexpected value type') + if type(value) ~= type(options_default[key]) then + error('unexpected value type', 2) + end reaper.SetExtState(EXT_STATE_ROOT, key, tostring(value), true) options_cache[key] = value end, @@ -188,12 +207,12 @@ end local function centerNextWindow(ctx) local center_x, center_y = ImGui.Viewport_GetCenter(ImGui.GetWindowViewport(ctx)) - ImGui.SetNextWindowPos(ctx, center_x, center_y, ImGui.Cond_Appearing(), 0.5, 0.5) + ImGui.SetNextWindowPos(ctx, center_x, center_y, ImGui.Cond_Appearing, 0.5, 0.5) end local function alignNextItemRight(ctx, label, spacing) local item_spacing_w = spacing and - ImGui.GetStyleVar(ctx, ImGui.StyleVar_ItemSpacing()) or 0 + ImGui.GetStyleVar(ctx, ImGui.StyleVar_ItemSpacing) or 0 local want_pos_x = ImGui.GetScrollX(ctx) + ImGui.GetContentRegionMax(ctx) - item_spacing_w - ImGui.CalcTextSize(ctx, label, nil, nil, true) @@ -225,8 +244,7 @@ local function alignGroupRight(ctx, callback) end local function tooltip(ctx, text) - if not ImGui.IsItemHovered(ctx, ImGui.HoveredFlags_DelayShort()) or - not ImGui.BeginTooltip(ctx) then return end + if not ImGui.BeginItemTooltip(ctx) then return end ImGui.PushTextWrapPos(ctx, ImGui.GetFontSize(ctx) * 42) ImGui.Text(ctx, text) ImGui.PopTextWrapPos(ctx) @@ -247,11 +265,12 @@ local function progressBar(ctx, value) end local report_columns = (function() - local def_sort_desc = ImGui.TableColumnFlags_PreferSortDescending() - local def_hide = ImGui.TableColumnFlags_DefaultHide() - local frac_flags = def_sort_desc | ImGui.TableColumnFlags_WidthStretch() + local no_hide = ImGui.TableColumnFlags_NoHide + local def_sort_desc = ImGui.TableColumnFlags_PreferSortDescending + local def_hide = ImGui.TableColumnFlags_DefaultHide + local frac_flags = def_sort_desc | ImGui.TableColumnFlags_WidthStretch return { - { name = 'Name', field = 'name', width = 227 }, + { name = 'Name', field = 'name', width = 227, flags = no_hide }, { name = 'Source', field = 'src', width = 132 }, { name = 'Line', field = 'src_line', func = textCell, }, { name = '% of total', field = 'time_frac', @@ -259,7 +278,7 @@ local report_columns = (function() { name = '% of parent', field = 'time_frac_parent', func = progressBar, flags = frac_flags | def_hide }, { name = 'Time', field = 'time', func = textCell, fmt = formatTime, - flags = def_sort_desc | ImGui.TableColumnFlags_DefaultSort() }, + flags = def_sort_desc | ImGui.TableColumnFlags_DefaultSort }, { name = 'Calls', field = 'count', func = textCell, fmt = formatNumber, flags = def_sort_desc }, { name = 'MinT/c', field = 'time_per_call_min', @@ -318,7 +337,7 @@ end local function leave() local now = getTime() if profile_cur == profile then - error('unbalanced leave (missing call to enter)') + error('unbalanced leave (missing call to enter)', 3) end local time = now - profile_cur.enter_time @@ -329,7 +348,7 @@ local function leave() if not time_per_call.max or time > time_per_call.max then time_per_call.max = time end - profile_cur.time, profile_cur.enter_time = profile_cur.time + time, now + profile_cur.time = profile_cur.time + time profile_cur = profile_cur.parent end @@ -340,8 +359,9 @@ end local function setActive(frame_count, force_auto) if frame_count == true then frame_count = -1 elseif not frame_count then frame_count = 0 end - assert(type(frame_count) == 'number', - 'value must be nil, a boolean, or an integer') + if type(frame_count) ~= 'number' then + error('value must be nil, a boolean, or an integer', 3) + end frame_count = math.floor(frame_count) if frame_count ~= 0 then @@ -372,25 +392,6 @@ local function setActiveFromUI(frame_count) end end -local function setCurrentProfile(i) - local now = getTime() - - state.current = i - if not profiles[i] then - profiler.reset() - else - profile, profile_cur = profiles[i], profiles[i] - end - - if active then - profile.start_time, profile.user_start_time = now, now - elseif state.auto_active ~= 0 then - profile.user_start_time = now - end - - state.scroll_to_top = true -end - local function eachDeep(tbl) local key_stack, depth = {}, 1 return function() @@ -440,7 +441,7 @@ local function sortReport() if l ~= r then -- table.sort is not stable: using id to preserve relative positions if l[field] == r[field] then return l.id < r.id end - if state.sort.dir == ImGui.SortDirection_Ascending() then + if state.sort.dir == ImGui.SortDirection_Ascending then return l[field] < r[field] else return l[field] > r[field] @@ -454,9 +455,16 @@ end local function updateReport() assert(profile_cur == profile, 'unbalanced enter (missing call to leave)') + if profile.dirty then + profile.dirty = false + else + return + end + profile.report = { max_depth = 1 } - local id, parents, flatten = 1, {}, not options.tree_view and {} + local id, parents, flatten = 1, {}, options.view == 0 and {} + local flame_graph = options.view == 2 for key, line, depth in eachDeep(profile) do if flatten then depth = 1 end if depth > profile.report.max_depth then @@ -498,7 +506,19 @@ local function updateReport() end if flatten then flatten[key] = report end - profile.report[#profile.report + 1] = report + + local subtree + if flame_graph then + subtree = profile.report[depth] + if not subtree then + subtree = {} + profile.report[depth] = subtree + end + else + subtree = profile.report + end + + subtree[#subtree + 1] = report end local parent = parents[depth - 1] @@ -516,12 +536,54 @@ local function updateReport() report.calls_per_frame_avg = report.frames and report.count // report.frames end - if state.sort.col then sortReport() end + if flame_graph then + local field = 'time_frac' + for i = 1, #profile.report do + table.sort(profile.report[i], function(a, b) + if i > 1 then + local parent_a, parent_b = a.parents[i - 1], b.parents[i - 1] + if parent_a[field] < parent_b[field] then + return false + elseif parent_a[field] > parent_b[field] then + return true + end + end + if parent_a == parent_b then + if a[field] == b[field] then return a.id < b.id end -- for stability + return a[field] > b[field] + end + end) + end + elseif state.sort.col then + sortReport() + end end -local function callLeave(func, ...) +local function setCurrentProfile(i) + local now = getTime() + + state.current = i + if not profiles[i] then + profiler.clear() + else + profile, profile_cur = profiles[i], profiles[i] + end + + if active then + profile.start_time, profile.user_start_time = now, now + elseif state.auto_active ~= 0 then + profile.user_start_time = now + end + + profile.dirty = true -- always refresh to apply new display and sort options + updateReport() -- refresh now to avoid 1-frame flicker + + state.set_scroll, state.zoom = { 0, 0 }, 1 +end + +local function callLeave(...) -- faster than capturing func's return values in a table + table.unpack - leave(func) + leave() return ... end @@ -535,7 +597,7 @@ local function makeWrapper(name, func) wrapper = function(...) if not active then return func(...) end enter(func, name) - return callLeave(func, func(...)) + return callLeave(func(...)) end attachments[wrapper], wrappers[func] = func, wrapper @@ -561,7 +623,9 @@ local function eachLocals(level, search_above) end local function getHostVar(path, level) - assert(type(path) == 'string', 'variable name must be a string') + if type(path) ~= 'string' then + error('variable name must be a string', level) + end local off, sep = 1, string.find(path, '[%.`]') local seg = string.sub(path, off, sep and sep - 1) @@ -591,15 +655,17 @@ local function getHostVar(path, level) match = nil break end + elseif type(match) ~= 'table' then + error(string.format('%s is not a table', string.sub(path, 1, off - 2)), level) else - assert(type(match) == 'table', - string.format('%s is not a table', string.sub(path, 1, sep and sep - 1))) parent, match = match, match[seg] end end - assert(match, string.format('variable not found: %s', - string.sub(path, 1, sep and sep - 1))) + if not match then + error(string.format('variable not found: %s', + string.sub(path, 1, sep and sep - 1)), level) + end return match, level - 1, local_idx, parent, seg end @@ -662,8 +728,10 @@ local function attachToVar(is_attach, var, opts) local val, level, idx, parent, parent_key = getHostVar(var, 4) -- start at depth=0 to attach to tables by name with opts.recursion=false local ok, wrapper = attach(is_attach, var, val, opts, 0) - assert(ok, string.format('%s is not %s', - var, is_attach and 'attachable' or 'detachable')) + if not ok then + error(string.format('%s is not %s', + var, is_attach and 'attachable' or 'detachable'), 3) + end if wrapper then if idx then debug.setlocal(level, idx, wrapper) end if parent then parent[parent_key] = wrapper end @@ -698,7 +766,7 @@ function profiler.detachFromWorld() attachToTable(false, nil, _G, opts, 1) end -function profiler.reset() +function profiler.clear() profiles[state.current] = { time = 0, children = {}, @@ -711,7 +779,9 @@ function profiler.reset() end function profiler.start() - assert(not active, 'profiler is already active') + if active then + error('profiler is already active', 2) + end active = true -- prevent the garbage collector from affecting measurement repeatability @@ -735,7 +805,11 @@ function profiler.leave() end function profiler.stop() - assert(active, 'profiler is not active') + if profile_cur ~= profile then + error('unbalanced enter (missing call to leave)', 2) + elseif not active then + error('profiler is not active', 2) + end updateTime() -- before setting active to false active = false @@ -767,7 +841,9 @@ local function updateFrame(line) end function profiler.frame() - assert(not active, 'profiler must be stopped before calling frame') + if active then + error('profiler must be stopped before calling frame', 2) + end for key, line in eachDeep(profile) do if line.count > line.prev_count then @@ -799,10 +875,9 @@ function profiler.frame() end function profiler.showWindow(ctx, p_open, flags) - flags = (flags or 0) | - ImGui.WindowFlags_MenuBar() + flags = (flags or 0) | ImGui.WindowFlags_MenuBar - ImGui.SetNextWindowSize(ctx, 850, 500, ImGui.Cond_FirstUseEver()) + ImGui.SetNextWindowSize(ctx, 850, 500, ImGui.Cond_FirstUseEver) local host = select(2, reaper.get_action_context()) local self = string.sub(debug.getinfo(1, 'S').source, 2) @@ -847,21 +922,28 @@ function profiler.showWindow(ctx, p_open, flags) end if ImGui.BeginMenu(ctx, 'Profile') then local has_data = profile.start_time ~= nil - if ImGui.MenuItem(ctx, 'Tree view', nil, options.tree_view) then - options.tree_view, profile.dirty = not options.tree_view, true + if ImGui.MenuItem(ctx, 'Flat list', nil, options.view == 0) then + options.view, profile.dirty = 0, true + end + if ImGui.MenuItem(ctx, 'Tree view', nil, options.view == 1) then + options.view, profile.dirty = 1, true + end + if ImGui.MenuItem(ctx, 'Flame graph', nil, options.view == 2) then + options.view, profile.dirty = 2, true end - if ImGui.MenuItem(ctx, 'Reset', nil, nil, has_data) then - profiler.reset() + ImGui.Separator(ctx) + if ImGui.MenuItem(ctx, 'Clear', nil, nil, has_data) then + profiler.clear() end ImGui.EndMenu(ctx) end if ImGui.BeginMenu(ctx, 'Help', reaper.CF_ShellExecute ~= nil) then - if ImGui.MenuItem(ctx, 'Donate...') then - reaper.CF_ShellExecute('https://reapack.com/donate') - end if ImGui.MenuItem(ctx, 'Forum thread') then reaper.CF_ShellExecute('https://forum.cockos.com/showthread.php?t=283461') end + if ImGui.MenuItem(ctx, 'Donate...') then + reaper.CF_ShellExecute('https://reapack.com/donate') + end ImGui.EndMenu(ctx) end local fps = string.format('%04.01f FPS##fps', ImGui.GetFramerate(ctx)) @@ -880,7 +962,7 @@ function profiler.showWindow(ctx, p_open, flags) end centerNextWindow(ctx) if ImGui.BeginPopupModal(ctx, 'Frame measurement', true, - ImGui.WindowFlags_AlwaysAutoResize()) then + ImGui.WindowFlags_AlwaysAutoResize) then ImGui.Text(ctx, 'Frame measurement requires usage of a proxy defer function.') ImGui.Spacing(ctx) @@ -897,9 +979,11 @@ function profiler.showWindow(ctx, p_open, flags) if ImGui.IsWindowAppearing(ctx) then ImGui.SetKeyboardFocusHere(ctx) end - local snippet = 'reaper.defer = profiler.defer' + local snippet = '\z + reaper.defer = profiler.defer\n\z + reaper.runloop = profiler.runloop' ImGui.InputTextMultiline(ctx, '##snippet', snippet, - -FLT_MIN, ImGui.GetFontSize(ctx) * 3, ImGui.InputTextFlags_ReadOnly()) + -FLT_MIN, ImGui.GetFontSize(ctx) * 3, ImGui.InputTextFlags_ReadOnly) ImGui.Spacing(ctx) ImGui.Text(ctx, 'Do you wish to enable acquisition anyway?') @@ -914,8 +998,9 @@ function profiler.showWindow(ctx, p_open, flags) ImGui.EndDisabled(ctx) ImGui.SameLine(ctx) if ImGui.Button(ctx, 'Inject proxy and continue') then - _G['reaper'].defer, state.defer_called = profiler.defer, true - setActive(state.want_activate) + _G['reaper'].defer, _G['reaper'].runloop = profiler.defer, profiler.runloop + state.defer_called = true + setActive(state.want_activate) -- reads defer_called and sets want_activate state.want_activate = nil ImGui.CloseCurrentPopup(ctx) end @@ -936,76 +1021,13 @@ function profiler.showWindow(ctx, p_open, flags) return p_open end -function profiler.showProfile(ctx, label, width, height) - if not ImGui.BeginChild(ctx, label, width, height) then return end - - if ImGui.IsWindowAppearing(ctx) then - ImGui.SetKeyboardFocusHere(ctx) - end - if ImGui.IsWindowFocused(ctx, ImGui.FocusedFlags_ChildWindows()) then - local key_0, pad_0 = ImGui.Key_0(), ImGui.Key_Keypad0() - for i = 1, PROFILES_SIZE do - if ImGui.IsKeyPressed(ctx, key_0 + i) or - ImGui.IsKeyPressed(ctx, pad_0 + i) then - setCurrentProfile(i) - end - end - end - - updateTime() -- may set dirty - if profile.dirty then - updateReport() - profile.dirty = false - end - - local summary = string.format('Active time / wall time: %s / %s (%.02f%%)', - formatTime(profile.time, false), formatTime(profile.total_time or 0, false), - (profile.time / (profile.total_time or 1)) * 100) - ImGui.Text(ctx, summary) - if isAnyActive() then - ImGui.SameLine(ctx, nil, 0) - ImGui.Text(ctx, string.format('%-3s', - string.rep('.', ImGui.GetTime(ctx) // 1 % 3 + 1))) - end - ImGui.SameLine(ctx) - - local export = false - alignGroupRight(ctx, function() - for i = 1, PROFILES_SIZE do - if i > 1 then ImGui.SameLine(ctx, nil, 4) end - local was_current = i == state.current - if was_current then - ImGui.PushStyleColor(ctx, ImGui.Col_Button(), - ImGui.GetStyleColor(ctx, ImGui.Col_HeaderActive())) - end - if ImGui.SmallButton(ctx, i) then - setCurrentProfile(i) - end - if was_current then - ImGui.PopStyleColor(ctx) - end - end - ImGui.SameLine(ctx) - if ImGui.SmallButton(ctx, 'Copy to clipboard') then - export = true - ImGui.LogToClipboard(ctx) - ImGui.LogText(ctx, summary .. '\n\n') - end - end, true) - ImGui.Spacing(ctx) - - if state.scroll_to_top then - ImGui.SetNextWindowScroll(ctx, 0, 0) - state.scroll_to_top = false - end - local flags = ImGui.TableFlags_SizingFixedFit() | - ImGui.TableFlags_Resizable() | ImGui.TableFlags_Reorderable() | - ImGui.TableFlags_Hideable() | ImGui.TableFlags_Sortable() | - ImGui.TableFlags_ScrollX() | ImGui.TableFlags_ScrollY() | - ImGui.TableFlags_Borders() | ImGui.TableFlags_RowBg() - if not ImGui.BeginTable(ctx, 'table', 17, flags) then - return ImGui.EndChild(ctx) - end +local function tableView(ctx) + local flags = ImGui.TableFlags_SizingFixedFit | + ImGui.TableFlags_Resizable | ImGui.TableFlags_Reorderable | + ImGui.TableFlags_Hideable | ImGui.TableFlags_Sortable | + ImGui.TableFlags_ScrollX | ImGui.TableFlags_ScrollY | + ImGui.TableFlags_Borders | ImGui.TableFlags_RowBg + if not ImGui.BeginTable(ctx, 'table', 17, flags) then return end ImGui.TableSetupScrollFreeze(ctx, 0, 1) for i, column in ipairs(report_columns) do @@ -1014,41 +1036,45 @@ function profiler.showProfile(ctx, label, width, height) ImGui.TableHeadersRow(ctx) if ImGui.TableNeedSort(ctx) then - local ok, id, col, order, dir = - ImGui.TableGetColumnSortSpecs(ctx, 0) + local ok, col, user_col, dir = ImGui.TableGetColumnSortSpecs(ctx, 0) if ok and (col ~= state.sort.col or dir ~= state.sort.dir) then state.sort = { col = col, dir = dir } sortReport() end end - local tree_node_flags = ImGui.TreeNodeFlags_SpanFullWidth() | - ImGui.TreeNodeFlags_DefaultOpen() | ImGui.TreeNodeFlags_FramePadding() + local tree_node_flags = ImGui.TreeNodeFlags_SpanAllColumns | + ImGui.TreeNodeFlags_DefaultOpen | ImGui.TreeNodeFlags_FramePadding local tree_node_leaf_flags = tree_node_flags | - ImGui.TreeNodeFlags_Leaf() | ImGui.TreeNodeFlags_NoTreePushOnOpen() + ImGui.TreeNodeFlags_Leaf | ImGui.TreeNodeFlags_NoTreePushOnOpen local i, prev_depth, cut_src_cache = 1, 1, {} - ImGui.PushStyleVar(ctx, ImGui.StyleVar_FramePadding(), 1, 1) - ImGui.PushStyleVar(ctx, ImGui.StyleVar_IndentSpacing(), 12) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_FramePadding, 1, 1) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_IndentSpacing, 12) while i <= #profile.report do local line = profile.report[i] - for i = #line.parents, prev_depth - 1 do ImGui.TreePop(ctx) end + for j = #line.parents, prev_depth - 1 do ImGui.TreePop(ctx) end prev_depth = #line.parents ImGui.TableNextRow(ctx) ImGui.TableNextColumn(ctx) if profile.report.max_depth > 1 then + local tooltip_text if line.children > 0 then if not ImGui.TreeNodeEx(ctx, line.key, line.name, tree_node_flags) then i = i + line.children end - tooltip(ctx, string.format('%s (%d children)', - line.name, formatNumber(line.children))) + tooltip_text = string.format('%s (%d children)', + line.name, formatNumber(line.children)) else ImGui.TreeNodeEx(ctx, line.key, line.name, tree_node_leaf_flags) - tooltip(ctx, line.name) + tooltip_text = line.name + end + local flags = ImGui.TableGetColumnFlags(ctx) + if (flags & ImGui.TableColumnFlags_IsHovered) ~= 0 then + tooltip(ctx, tooltip_text) end else ImGui.AlignTextToFramePadding(ctx) @@ -1068,7 +1094,7 @@ function profiler.showProfile(ctx, label, width, height) if v and col.func then ImGui.TableNextColumn(ctx) local flags = ImGui.TableGetColumnFlags(ctx) - if flags & ImGui.TableColumnFlags_IsVisible() ~= 0 then + if flags & ImGui.TableColumnFlags_IsVisible ~= 0 then col.func(ctx, col.fmt and col.fmt(v) or v) end end @@ -1079,6 +1105,266 @@ function profiler.showProfile(ctx, label, width, height) for i = 2, prev_depth do ImGui.TreePop(ctx) end ImGui.PopStyleVar(ctx, 2) ImGui.EndTable(ctx) +end + +local function setZoom(ctx, zoom, scroll_x, avail_w) + zoom = math.max(1, math.min(512, zoom)) + if state.zoom == zoom then return end + + local new_w = (avail_w * zoom) // 1 + state.set_content_size = { new_w, 0 } + + if scroll_x then + scroll_x = scroll_x * zoom + else + local mouse_x = ImGui.GetMousePos(ctx) - + ImGui.GetWindowPos(ctx) - ImGui.GetCursorStartPos(ctx) + scroll_x = ImGui.GetScrollX(ctx) + ((mouse_x * (zoom / state.zoom)) - mouse_x) + end + state.set_scroll = { scroll_x // 1, -1 } + + state.zoom = zoom +end + +local function reportLineTooltip(ctx, line) + ImGui.SetNextWindowSize(ctx, 300, 0) + if not ImGui.BeginItemTooltip(ctx) then return end + if not ImGui.BeginTable(ctx, 'tooltip', 2) then + ImGui.EndTooltip(ctx) + return + end + + local stats = {} + ImGui.TableSetupColumn(ctx, 'key', ImGui.TableColumnFlags_WidthFixed) + ImGui.TableSetupColumn(ctx, 'value', ImGui.TableColumnFlags_WidthStretch) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_FramePadding, 0, 0) + for j, col in ipairs(report_columns) do + local stat_which, stat_name = col.name:match('^(.-)(./.-)$') + local v, show = nil, false + if col.field == 'src' then + v, show = line.src_short .. ':' .. line.src_line, true + elseif stat_which then + local stat = stats[#stats] + if not stat or stat.name ~= stat_name then + stat = { name = stat_name } + stats[#stats + 1] = stat + end + stat[#stat + 1] = { which = stat_which, line = line, col = col } + elseif col.field ~= 'src_line' then + v, show = line[col.field], true + end + if show then + ImGui.TableNextRow(ctx) + ImGui.TableNextColumn(ctx) + textCell(ctx, col.name .. ':') + if v then + ImGui.TableNextColumn(ctx) + if col.fmt then v = col.fmt(v) end + (col.func or ImGui.TextWrapped)(ctx, v) + end + end + end + + ImGui.TableNextRow(ctx) + ImGui.TableSetColumnIndex(ctx, 1) + if ImGui.BeginTable(ctx, 'stats', 3, + ImGui.TableFlags_Borders | ImGui.TableFlags_SizingStretchSame) then + for _, val in ipairs(stats[1]) do + ImGui.TableSetupColumn(ctx, val.which) + end + ImGui.TableHeadersRow(ctx) + for _, stat in ipairs(stats) do + ImGui.TableNextRow(ctx) + stat.y = ImGui.GetCursorPosY(ctx) + for _, val in ipairs(stat) do + ImGui.TableNextColumn(ctx) + local v = val.line[val.col.field] + if v then + val.col.func(ctx, val.col.fmt(v)) + else + ImGui.NewLine(ctx) + end + end + end + ImGui.EndTable(ctx) + end + ImGui.TableNextColumn(ctx) + for _, stat in ipairs(stats) do + ImGui.SetCursorPosY(ctx, stat.y) + textCell(ctx, stat.name .. ':') + end + + ImGui.PopStyleVar(ctx) + ImGui.EndTable(ctx) + ImGui.EndTooltip(ctx) +end + +local function flameGraph(ctx) + local is_zooming = ImGui.GetKeyMods(ctx) & ~ImGui.Mod_Shift ~= 0 + local window_flags = ImGui.WindowFlags_HorizontalScrollbar + if is_zooming then + window_flags = window_flags | ImGui.WindowFlags_NoScrollWithMouse + end + + if not ImGui.BeginChild(ctx, 'graph', 0, 0, + ImGui.ChildFlags_Border, window_flags) then return end + local draw_list, border_color = ImGui.GetWindowDrawList(ctx), 0x566683FF + local tiny_w, avail_w, zoom = 2, ImGui.GetContentRegionAvail(ctx), state.zoom + if state.did_set_content_size then avail_w = math.ceil(avail_w / zoom) end + ImGui.PushStyleColor(ctx, ImGui.Col_Button, 0x23446CFF) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_ItemSpacing, 0, 0) + for i = 1, #profile.report do + local level = profile.report[i] + local first_of_line, prev_parent = true, nil + local x, start_x = 0, ImGui.GetCursorPosX(ctx) + local visible_x = ImGui.GetScrollX(ctx) + start_x + for j = 1, #level do + local item = level[j] + ImGui.PushID(ctx, item.id) + + if first_of_line then + first_of_line = false + else + ImGui.SameLine(ctx) + end + + local parent = item.parents[i - 1] + local parent_w = parent and parent.w or avail_w + if parent and prev_parent ~= parent then x = parent.x end + item.x, item.w = x, parent_w * item.time_frac_parent + + local display_x = start_x + (x * zoom) // 1 + local display_w = math.max(1, item.w * zoom) // 1 + + -- move the labels at the center of the view + if display_x <= visible_x then + local old_display_x = display_x + display_x = math.max(display_x, visible_x - start_x) + display_w = math.max(1, display_w - (display_x - old_display_x)) + end + if display_x + display_w >= visible_x + avail_w then + local offset_x = math.max(0, display_x - visible_x) + display_w = math.min(display_w, (avail_w - offset_x) + (start_x * 2)) + display_w = math.max(1, display_w) + end + + ImGui.SetCursorPosX(ctx, display_x) + if display_w < tiny_w then + ImGui.PushStyleColor(ctx, ImGui.Col_Button, border_color) + ImGui.SetNextItemAllowOverlap(ctx) + end + if ImGui.Button(ctx, item.name, display_w) then + setZoom(ctx, avail_w / item.w, item.x, avail_w) + ImGui.SetScrollHereY(ctx, 0) + end + if display_w < tiny_w then + ImGui.PopStyleColor(ctx) + else + -- not using StyleVar_FrameBorderSize to collapse borders + -- border color cannot have alpha because it's partially over buttons + local x1, y1 = ImGui.GetItemRectMin(ctx) + local x2, y2 = ImGui.GetItemRectMax(ctx) + ImGui.DrawList_AddRect(draw_list, x1, y1, x2 + 1, y2 + 1, border_color) + end + + reportLineTooltip(ctx, item) + + prev_parent = parent + x = x + item.w + ImGui.PopID(ctx) + end + + if i == 1 then + local full_w = (avail_w * zoom) // 1 + local idle_w = full_w - (x * zoom) // 1 + if idle_w > 0 then + ImGui.SameLine(ctx) + ImGui.SetCursorPosX(ctx, start_x + (x * zoom) // 1) + ImGui.Dummy(ctx, idle_w, 1) + end + end + end + ImGui.PopStyleVar(ctx) + ImGui.PopStyleColor(ctx) + + if is_zooming and ImGui.IsWindowHovered(ctx) then + local mouse_wheel = ImGui.GetMouseWheel(ctx) / 64 + if mouse_wheel ~= 0 then + setZoom(ctx, zoom * (1 + mouse_wheel), nil, avail_w) + end + end + + ImGui.EndChild(ctx) +end + +function profiler.showProfile(ctx, label, width, height, child_flags) + if not ImGui.BeginChild(ctx, label, width, height, child_flags) then return end + + updateTime() -- may set dirty + updateReport() + + if ImGui.IsWindowAppearing(ctx) then + ImGui.SetKeyboardFocusHere(ctx) + end + if ImGui.IsWindowFocused(ctx, ImGui.FocusedFlags_ChildWindows) then + local key_0, pad_0 = ImGui.Key_0, ImGui.Key_Keypad0 + for i = 1, PROFILES_SIZE do + if ImGui.IsKeyPressed(ctx, key_0 + i) or + ImGui.IsKeyPressed(ctx, pad_0 + i) then + setCurrentProfile(i) + end + end + end + + local summary = string.format('Active time / wall time: %s / %s (%.02f%%)', + formatTime(profile.time, false), formatTime(profile.total_time or 0, false), + (profile.time / (profile.total_time or 1)) * 100) + ImGui.Text(ctx, summary) + if isAnyActive() then + ImGui.SameLine(ctx, nil, 0) + ImGui.Text(ctx, string.format('%-3s', + string.rep('.', ImGui.GetTime(ctx) // 1 % 3 + 1))) + end + ImGui.SameLine(ctx) + + local export = false + alignGroupRight(ctx, function() + for i = 1, PROFILES_SIZE do + if i > 1 then ImGui.SameLine(ctx, nil, 4) end + local was_current = i == state.current + if was_current then + ImGui.PushStyleColor(ctx, ImGui.Col_Button, + ImGui.GetStyleColor(ctx, ImGui.Col_HeaderActive)) + end + if ImGui.SmallButton(ctx, i) then + setCurrentProfile(i) + end + if was_current then + ImGui.PopStyleColor(ctx) + end + end + ImGui.SameLine(ctx) + if ImGui.SmallButton(ctx, 'Copy to clipboard') then + export = true + ImGui.LogToClipboard(ctx) + ImGui.LogText(ctx, summary .. '\n\n') + end + end) + ImGui.Spacing(ctx) + + if state.set_content_size then + ImGui.SetNextWindowContentSize(ctx, table.unpack(state.set_content_size)) + state.set_content_size = nil + state.did_set_content_size = true + elseif state.did_set_content_size then + state.did_set_content_size = false + end + if state.set_scroll then + ImGui.SetNextWindowScroll(ctx, table.unpack(state.set_scroll)) + state.set_scroll = nil + end + + (options.view == 2 and flameGraph or tableView)(ctx) if export then ImGui.LogFinish(ctx) end @@ -1098,6 +1384,8 @@ function profiler.defer(callback) end) end +profiler.runloop = profiler.defer + function profiler.run() if state.run_called then return end state.run_called = true @@ -1127,15 +1415,15 @@ setmetatable(profiler, { end, }) -profiler.reset() +profiler.clear() -- if not CF_PROFILER_SELF then -- CF_PROFILER_SELF = true --- local self_profile = dofile(debug.getinfo(1, 'S').source:sub(2)) +-- local self_profiler = dofile(debug.getinfo(1, 'S').source:sub(2)) -- CF_PROFILER_SELF = nil --- reaper.defer = self_profile.defer --- self_profile.attachToLocals({ search_above = false }) --- self_profile.run() +-- reaper.defer = self_profiler.defer +-- self_profiler.attachToLocals({ search_above = false }) +-- self_profiler.run() -- end return profiler