diff --git a/README.md b/README.md index 90cc811..9376d5a 100644 --- a/README.md +++ b/README.md @@ -33,39 +33,67 @@ Then calling `prof.write("myfile.prof")` will save a file to your applications s # Documentation Before you annotate your code, you need to copy (not move) `jprof.lua` and `MessagePack.lua` into your game's directory. -If you want to capture a profiling file, you need to set `PROF_CAPTURE` before you import jprof: +The most common case does probably look somewhat like this: ```lua PROF_CAPTURE = true -prof = require "jprof" +prof = require("jprof") + +function love.update(dt) + prof.push("frame") + -- push and pop additional zones here + -- also update your game if you want +end + +function love.draw() + -- push and pop additional zones here + prof.pop("frame") +end ``` If `PROF_CAPTURE` evaluates to `false` when jprof is imported, all profiling functions are replaced with `function() end` i.e. do nothing, so you can leave them in even for release builds. -Also all other zones have to be pushed inside the `"frame"` zone and whenever `prof.push` or `prof.pop` are called outside of a frame, the viewer will not know how to interpret that data (and error). The idiomatic use is therefore something like this: +Also all other zones have to be pushed inside the `"frame"` zone and whenever `prof.push` or `prof.pop` are called outside of a frame, the viewer will not know how to interpret that data (and error). So make sure capturing is disabled when functions are called that push zones outside of the `"frame"` zone. + +For example if you are using a fixed timestep loop (update and draw frames are not always 1 for 1), you probably want to do something like this instead (implementation of the fixed timestep loop not included): + ```lua +PROFILE_DRAW = false + function love.update(dt) - prof.enabled(true) + prof.enabled(not PROFILE_DRAW) prof.push("frame") - -- push and pop more here - -- also update your game if you want + -- updating prof.pop("frame") prof.enabled(false) end -``` -This makes sure that if functions that push profiling zones are used outside of `love.update`, the captures can still be interpreted by the viewer. +function love.draw() + prof.enabled(PROFILE_DRAW) + prof.push("frame") + -- drawing + prof.pop("frame") + prof.enabled(false) +end +``` ### `prof.push(name, annotation)` -The annotation is optional and appears as metadata in the viewer. +The `annotation` is optional and appears as metadata in the viewer. ### `prof.pop(name)` -The name is optional and is only used to check if the current zone is actually the one specified as the argument. If not, somewhere before that pop-call another zone has been pushed, but not popped. +The `name` is optional and is only used to check if the current zone is actually the one specified as the argument. If not, somewhere before that pop-call another zone has been pushed, but not popped. ### `prof.write(filename)` -Writes the capture file +Writes the capture file to `filename`. ### `prof.enabled(enabled)` -Enables capturing profiling zones (`enabled = true`) or disables it (`enabled = false`) +Enables capturing profiling zones (`enabled = true`) or disables it (`enabled = false`). By default, profiling is enabled. + +### `prof.connect(saveFullProfData, port, address)` +Attempts to connect to the jprof viewer to transmit realtime profiling data. If `saveFullProfData` is `true`, jprof will still save all the profiling data, so you can save it to file later using `prof.write()`. If it is `false` (default), the data is only transmitted to the viewer and `prof.write()` will show a notice that no profiling data was saved. +The default `port` is `1338` and the default `address` is `localhost`. + +### `prof.netFlush()` +jprof does not send out every event by itself, but rather buffers them and sends them out, when this command is called. By default this is called when `prof.pop()` is called and the popped zone is `"frame"` (though only if you did `prof.connect()` earlier). ## Viewer Just start the löve project contained in this repository like this: @@ -74,13 +102,27 @@ love jprof ``` With `` being the [identity](https://love2d.org/wiki/love.filesystem.setIdentity) (most commonly set in [conf.lua](https://love2d.org/wiki/Config_Files)) of your project and `` being the filename of the capture file (the one that was passed to `prof.write(filename)`). -### Controls -You can seek frames with left click. If you hold shift while pressing left click the previously selected frame and the newly clicked frame will be averaged into a frame range, which is highly advised to find bottlenecks or get a general idea of memory development when you are not interested in a particular frame. +### Realtime Profiling +jprof also supports realtime transmission of profiling data. To use this feature, just start the viewer in listen mode: +```console +love jprof listen +``` +You may also pass an additional, optional argument to specify a port. The default port used is 1338. In the program you are profiling, call `prof.connect()` (see above) right after importing jprof. + +**Note:** When realtime profiling is used, it is not as straightforward to keep track of the memory jprof is using itself, since jprof will produce garbage too. Therefore the memory values returned by jprof will be less accurate and depending on your use case the garbage generated by jprof will dominate. Make sure to capture to file first and see if the live capture looks significantly different. + +### Notes +Hold `F1` or `H` to show the help overlay. -If a single frame is selected, you can additionally navigate using the left and right arrow key and skip 100 instead of 1 frame, if you also hold ctrl. +When you select a frame range, it will be averaged. Most of the time this is what you want to look at rather than individual frames. If a single frame is selected the position of the zones in the flame graph will correspond to their relative position in time inside the frame, for averaged frames both in memory and time mode the zones will just be centered above their parent. Their size will still hold meaning though and empty space surrounding these zones implies that there was memory consumed/freed or time spent without being enclosed by a profiling zone. -With the space key you can switch between memory and time mode, which will scale and position the zones inside the flame graph according to memory memory consumption changes or time duration respectively. +The different modes (`memory` and `time`) determine whether the scale and position of the zones inside the flame graph will be derived from either memory consumption changes or time duration respectively. The purple graph displays the total duration of the frames over time and the green graph the total memory consumption over time. + +### Graph Averaging Modes +* `max` mean is most useful for finding spikes. This is the default. +* `arithmetic` mean is what most people think of, when they think of an average. This is less sensitive to spikes, but still somewhat. +* `harmonic` mean is least sensitive to outliers and should be a bit smoother than the arithmetic mean. diff --git a/const.lua b/const.lua index dfd01f2..8b6beef 100644 --- a/const.lua +++ b/const.lua @@ -1,9 +1,14 @@ local const = { + defaultPort = 1338, + frameOverviewHeight = 40, graphHeightFactor = 0.3, infoLineHeight = 35, nodeHeight = 40, + noticeDuration = 4, + noticeFadeoutAfter = 3, + -- colors textColor = {1, 1, 1}, @@ -18,6 +23,10 @@ local const = { graphBorderColor = {0.3, 0.3, 0.3}, timeGraphColor = {1, 0, 1}, memGraphColor = {0, 1, 0}, + + helpOverlayColor = {0, 0, 0, 0.8}, + helpTitleColor = {1, 1, 1}, + helpColor = {0.85, 0.85, 0.85}, } const.graphYOffset = const.frameOverviewHeight + 20 diff --git a/draw.lua b/draw.lua index 69eec5d..4a8155b 100644 --- a/draw.lua +++ b/draw.lua @@ -2,15 +2,21 @@ local lg = love.graphics local const = require("const") local util = require("util") +local frames = require("frames") local draw = {} -function draw.getGraphCoords() - local winH = love.graphics.getHeight() - local graphHeight = winH * const.graphHeightFactor - local graphY = winH - const.graphYOffset - graphHeight - return graphY, graphHeight -end +draw.graphMean = "max" +draw.nextGraphMean = { + max = "arithmetic", + arithmetic = "harmonic", + harmonic = "max", +} + +draw.flameGraphType = "time" -- so far: "time" or "memory" + +local rootPath = {} +local rootPathHistory = {} local fonts = { mode = lg.newFont(22), @@ -18,6 +24,35 @@ local fonts = { graph = lg.newFont(12), } +-- the data to to be passed to love.graphics.line is saved here, so I don't create new tables all the time +local graphs = { + mem = {}, + time = {}, +} + +local noticeText = lg.newText(fonts.mode, "") +local noticeSent = 0 + +local helpText +do + local L = const.helpTitleColor + local R = const.helpColor + helpText = { + L, "Left Click (graph area): ", R, "Select a frame.\n", + L, "Shift + Left Click (graph area): ", R, "Select a frame range.\n\n", + + L, "Left Click (flame graph): ", R, "Select a node as the new root node.\n", + L, "Right Click (flame graph): ", R, "Return to the previous root node.\n\n", + + L, "Arrow Left/Right: ", R, "Seek 1 frame left/right.\n", + L, "Ctrl + Arrow Left/Right: ", R, "Seek 100 frames left/right.\n\n", + + L, "Space: ", R, "Switch between 'time' and 'memory' mode.\n\n", + + L, "Alt: ", R, "Cycle through graph averaging modes.\n\n", + } +end + local flameGraphFuncs = { time = function(node, child) local x @@ -53,7 +88,7 @@ local function getNodeString(node) end local str - if flameGraphType == "time" then + if draw.flameGraphType == "time" then str = ("- %.4f ms, %s"):format(node.deltaTime*1000, memStr) else str = ("- %s, %.4f ms"):format(memStr, node.deltaTime*1000) @@ -67,8 +102,7 @@ local function getNodeString(node) end local function renderSubGraph(node, x, y, width, graphFunc, center) - --print(node.name, x, y, width) - + prof.push("renderSubGraph") local border = 2 local font = lg.getFont() @@ -126,7 +160,7 @@ local function renderSubGraph(node, x, y, width, graphFunc, center) hovered = hovered or childHover end end - + prof.pop("renderSubGraph") return hovered end @@ -134,39 +168,123 @@ local function getFramePos(i) return lg.getWidth() / (#frames - 1) * (i - 1) end --- These are versions of the graphs in the graphs table, but with actual screen coordinates --- So they can be passed to love.graphics.line, instead of normalized data --- They are stored here so I don't have to create a couple of huge tables every draw -local drawGraphs = {} +local function buildGraph(graph, key, valueOffset, valueScale, mean, path) + prof.push("buildGraph") + local x, w = 0, lg.getWidth() + local y, h = draw.getGraphCoords() + + local numPoints = math.min(#frames, lg.getWidth()*4) + local frameIndex = 1 + local step = #frames / numPoints + for p = 1, numPoints do + local startIndex = math.floor(frameIndex) + local endIndex = math.floor(frameIndex + step - 1) + local accum = nil + local n = endIndex - startIndex + 1 + for f = startIndex, endIndex do + local node = util.getNodeByPath(frames[f], path) + if node then + accum = mean.add(accum, util.clamp((node[key] - valueOffset) / valueScale)) + end + end + frameIndex = frameIndex + step + graph[p*2-1+0] = x + (p - 1) / (numPoints - 1) * w + graph[p*2-1+1] = y + (1 - (mean.mean(accum, n) or 0)) * h + end + prof.pop("buildGraph") +end + +function draw.updateGraphs() + prof.push("draw.updateGraphs") + buildGraph(graphs.time, "deltaTime", 0, frames.maxDeltaTime, util.mean[draw.graphMean], rootPath) + buildGraph(graphs.mem, "memoryEnd", 0, frames.maxMemUsage, util.mean[draw.graphMean], rootPath) + prof.pop("draw.updateGraphs") +end + +function draw.getGraphCoords() + local winH = love.graphics.getHeight() + local graphHeight = winH * const.graphHeightFactor + local graphY = winH - const.graphYOffset - graphHeight + return graphY, graphHeight +end + +function draw.notice(str) + noticeText:set(str) + noticeSent = love.timer.getTime() +end + +local function setRootPath(path) + rootPath = path + draw.updateGraphs() + draw.notice("new draw root: " .. util.nodePathToStr(path)) +end + +function draw.pushRootPath(path) + table.insert(rootPathHistory, rootPath) + setRootPath(path) +end + +function draw.popRootPath(path) + if #rootPathHistory > 0 then + setRootPath(rootPathHistory[#rootPathHistory]) + table.remove(rootPathHistory) + end +end function love.draw() + prof.push("love.draw") local winW, winH = lg.getDimensions() - -- render frame overview at the bottom - local spacing = 1 - if winW / #frames < 3 then - spacing = 0 + if #frames < 1 then + lg.setFont(fonts.mode) + lg.print("Waiting for frames..", 5, 5) + + prof.pop("love.draw") + prof.pop("frame") + prof.enabled(false) + return end - local width = (winW - spacing) / #frames - spacing + + local mean = util.mean[draw.graphMean] + + -- render frame overview at the bottom + prof.push("heatmap") local vMargin = 5 + local numLines = math.min(#frames, winW) + local lineWidth = winW / numLines + local frameIndex = 1 + local step = #frames / numLines + for p = 1, numLines do + local startIndex = math.floor(frameIndex) + local endIndex = math.floor(frameIndex + step - 1) + local accum = nil + local n = endIndex - startIndex + 1 + for f = startIndex, endIndex do + accum = mean.add(accum, + util.clamp((frames[f].deltaTime - frames.minDeltaTime) / + (frames.maxDeltaTime - frames.minDeltaTime))) + end + frameIndex = frameIndex + step - for i, frame in ipairs(frames) do - local c = util.clamp((frame.deltaTime - frames.minDeltaTime) / - (frames.maxDeltaTime - frames.minDeltaTime)) + local x = lg.getWidth() / (numLines - 1) * (p - 1) + local y = winH - const.frameOverviewHeight + vMargin + local c = mean.mean(accum, n) lg.setColor(c, c, c) - local x, y = getFramePos(i) - width/2, winH - const.frameOverviewHeight + vMargin - lg.rectangle("fill", x, y, width, const.frameOverviewHeight - vMargin*2) + lg.rectangle("fill", x, y, lineWidth, const.frameOverviewHeight - vMargin*2) end + prof.pop("heatmap") local graphY, graphHeight = draw.getGraphCoords() + -- draw current frame/selection if frames.current.index then lg.setColor(const.frameCursorColor) local x = getFramePos(frames.current.index) lg.line(x, graphY, x, winH) else lg.setColor(const.frameSelectionColor) - local x, endX = getFramePos(frames.current.fromIndex), getFramePos(frames.current.toIndex) + local x = getFramePos(frames.current.fromIndex) + local endX = getFramePos(frames.current.toIndex) lg.rectangle("fill", x, graphY, endX - x, winH - graphY) end @@ -189,6 +307,7 @@ function love.draw() infoLine = ("frame %d: %.4f ms, %.3f KB"):format(frame, duration, memory) end + -- draw ticks local totalDur = frames[#frames].endTime - frames[1].startTime local tickInterval = 10 local numTicks = math.floor(totalDur / tickInterval) @@ -198,23 +317,15 @@ function love.draw() lg.line(x, graphY, x, graphY + graphHeight) end - for graphName, graphData in pairs(graphs) do - if not drawGraphs[graphName] then - drawGraphs[graphName] = {} - end - for i = 1, #graphData, 2 do - drawGraphs[graphName][i+0] = graphData[i+0] * winW - drawGraphs[graphName][i+1] = graphY + (1 - graphData[i+1]) * graphHeight - end - end - - lg.setColor(const.timeGraphColor) - lg.setLineWidth(1) - lg.line(drawGraphs.time) + if #frames > 1 then + lg.setLineWidth(1) + lg.setColor(const.timeGraphColor) + lg.line(graphs.time) - lg.setColor(const.memGraphColor) - lg.setLineWidth(2) - lg.line(drawGraphs.mem) + lg.setLineWidth(2) + lg.setColor(const.memGraphColor) + lg.line(graphs.mem) + end lg.setColor(const.textColor) local textY = graphY + graphHeight + 5 @@ -235,19 +346,55 @@ function love.draw() lg.print(("memory usage (max: %d KB)"):format(frames.maxMemUsage), 5, graphY) -- render flame graph for current frame + prof.push("flame graph") lg.setFont(fonts.mode) - lg.print("graph type: " .. flameGraphType, 5, 5) + lg.print("graph type: " .. draw.flameGraphType, 5, 5) lg.setFont(fonts.node) -- do not order flame layers (just center) if either memory graph or average frame - local hovered = renderSubGraph(frames.current, 0, graphY - const.infoLineHeight, winW, - flameGraphFuncs[flameGraphType], flameGraphType == "memory" or not frames.current.index) - if hovered then - infoLine = hovered.name .. " " .. getNodeString(hovered) + local node = util.getNodeByPath(frames.current, rootPath) + if node then + local hovered = renderSubGraph(node, 0, graphY - const.infoLineHeight, winW, + flameGraphFuncs[draw.flameGraphType], + flameGraphType == "memory" or not frames.current.index) + if hovered then + infoLine = hovered.name .. " " .. getNodeString(hovered) + + local mouseDown = love.mouse.isDown(1) + if mouseDown and not lastMouseDown then + draw.pushRootPath(hovered.path) + end + lastMouseDown = mouseDown + end + else + infoLine = ("This frame does not have a node with path '%s'"):format( + util.nodePathToStr(rootPath)) end + prof.pop("flame graph") if infoLine then lg.print(infoLine, 5, graphY - const.infoLineHeight + 5) end + + -- draw notice + local dt = love.timer.getTime() - noticeSent + if dt < const.noticeDuration then + local alpha = 1.0 - math.max(0, dt - const.noticeFadeoutAfter) / + (const.noticeDuration - const.noticeFadeoutAfter) + lg.setColor(1, 1, 1, alpha) + lg.draw(noticeText, winW - noticeText:getWidth() - 5, 5) + end + + -- draw help overlay + if love.keyboard.isDown("h") or love.keyboard.isDown("f1") then + lg.setColor(const.helpOverlayColor) + lg.rectangle("fill", 0, 0, winW, winH) + lg.setColor(1, 1, 1) + lg.printf(helpText, 20, 20, winW - 40) + end + + prof.pop("love.draw") + prof.pop("frame") + prof.enabled(false) end return draw diff --git a/frameAverage.lua b/frameAverage.lua index 699285b..61f02d7 100644 --- a/frameAverage.lua +++ b/frameAverage.lua @@ -36,15 +36,15 @@ end local function getFrameAverage(frames, fromFrame, toFrame) local frame = { - fromIndex = fromFrame.index, - toIndex = toFrame.index, + fromIndex = fromFrame, + toIndex = toFrame, name = "frame", deltaTime = 0, memoryDelta = 0, children = {}, } - for i = fromFrame.index, toFrame.index do + for i = fromFrame, toFrame do addNode(frame, frames[i]) end diff --git a/frames.lua b/frames.lua new file mode 100644 index 0000000..f8b3727 --- /dev/null +++ b/frames.lua @@ -0,0 +1,137 @@ +-- the frames itself are stored in this table as well +local frames = {} + +frames.current = nil + +frames.minDeltaTime, frames.maxDeltaTime = nil, nil +frames.minMemUsage, frames.maxMemUsage = nil, nil + +local function getNodeCount(node) + assert(node.parent) + local counter = 1 + for _, child in ipairs(node.parent.children) do + if child.name == node.name then + counter = counter + 1 + if child == node then + break + end + end + end + return counter +end + +local function buildNodeGraph(data) + prof.push("buildNodeGraph") + local frames = {} + local nodeStack = {} + for _, event in ipairs(data) do + local name, time, memory, annotation = unpack(event) + local top = nodeStack[#nodeStack] + if name ~= "pop" then + local node = { + name = name, + startTime = time, + memoryStart = memory, + annotation = annotation, + parent = top, + children = {}, + } + if top then + node.path = {unpack(top.path)} + table.insert(node.path, {node.name, getNodeCount(node)}) + else + node.path = {} + end + + if name == "frame" then + if #nodeStack > 0 then + error("Profiling data malformed: Pushed a new frame when the last one was not popped yet!") + end + + node.pathCache = {} + table.insert(frames, node) + else + if not top then + error("Profiling data malformed: Pushed a profiling zone without a 'frame' profiling zone on the stack!") + end + + table.insert(top.children, node) + end + + table.insert(nodeStack, node) + else + if not top then + error("Profiling data malformed: Popped a profiling zone on an empty stack!") + end + + top.endTime = time + 1e-8 + top.deltaTime = top.endTime - top.startTime + top.memoryEnd = memory + top.memoryDelta = top.memoryEnd - top.memoryStart + table.remove(nodeStack) + end + end + prof.pop("buildNodeGraph") + return frames +end + +local function updateRange(newFrames, valueList, key, cutoffPercent, cutoffMin) + if #frames == 0 then + for _, frame in ipairs(newFrames) do + table.insert(valueList, frame[key]) + end + table.sort(valueList) + else + for _, frame in ipairs(newFrames) do + local value = frame[key] + local i = 1 + while valueList[i] and valueList[i] < value do + i = i + 1 + end + table.insert(valueList, i, value) + i = i + 1 + end + end + + local margin = 0 + if cutoffPercent then + assert(cutoffMin) + -- cut off the lowest and highest cutoffPercent of the values + margin = math.max(cutoffMin, math.floor(cutoffPercent * #valueList)) + end + if cutoffMin and #valueList > cutoffMin * 5 then + return valueList[1 + margin], valueList[#valueList - margin] + else + return valueList[1], valueList[#valueList] + end +end + +local deltaTimes = {} +local memUsages = {} + +local function updateRanges(newFrames) + prof.push("updateRanges") + frames.minDeltaTime, frames.maxDeltaTime = + updateRange(newFrames, deltaTimes, "deltaTime", 0.005, 5) + frames.minMemUsage, frames.maxMemUsage = + updateRange(newFrames, memUsages, "memoryEnd") + prof.pop("updateRanges") +end + +function frames.addFrames(data) + prof.push("frames.addFrames") + local newFrames = buildNodeGraph(data) + + prof.push("extend list") + for i, frame in ipairs(newFrames) do + table.insert(frames, frame) + frame.index = #frames + end + frames.current = frames.current or frames[1] + prof.pop("extend list") + + updateRanges(newFrames) + prof.pop("frames.addFrames") +end + +return frames diff --git a/jprof.lua b/jprof.lua index 9975695..a930d9f 100644 --- a/jprof.lua +++ b/jprof.lua @@ -24,6 +24,7 @@ local profiler = {} local zoneStack = {nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil} local profData = {} +local netBuffer = nil local profEnabled = true -- profMem keeps track of the amount of memory allocated by prof.push/prof.pop -- which is then subtracted from collectgarbage("count"), @@ -31,7 +32,7 @@ local profEnabled = true local profMem = 0 local function getByte(n, byte) - return bit.rshift(bit.band(n, bit.lshift( 0xff, 8*byte )), 8*byte) + return bit.rshift(bit.band(n, bit.lshift(0xff, 8*byte)), 8*byte) end -- I need this function (and not just msgpack.pack), so I can pack and write @@ -44,9 +45,9 @@ local function msgpackListIntoFile(list, file) if n < 16 then file:write(string.char(144 + n)) elseif n < 0xFFFF then - file:write(string.char( 0xDC, getByte(n, 1), getByte(n, 0) )) + file:write(string.char(0xDC, getByte(n, 1), getByte(n, 0))) elseif n < 0xFFffFFff then - file:write(string.char( 0xDD, getByte(n, 3), getByte(n, 2), getByte(n, 1), getByte(n, 0))) + file:write(string.char(0xDD, getByte(n, 3), getByte(n, 2), getByte(n, 1), getByte(n, 0))) else error("List too big") end @@ -55,15 +56,37 @@ local function msgpackListIntoFile(list, file) end end +local function addEvent(name, memCount, annot) + local event = {name, love.timer.getTime(), memCount, annot} + if profData then + table.insert(profData, event) + end + if netBuffer then + table.insert(netBuffer, event) + end +end + if PROF_CAPTURE then function profiler.push(name, annotation) if not profEnabled then return end - local preCount = collectgarbage("count") - profMem + if #zoneStack == 0 then + assert(name == "frame", "(jprof) You may only push the 'frame' zone onto an empty stack") + end + + local memCount = collectgarbage("count") table.insert(zoneStack, name) - table.insert(profData, {name, love.timer.getTime(), preCount, annotation}) - -- not simplified for readability's sake - profMem = profMem + ((collectgarbage("count") - profMem) - preCount) + addEvent(name, memCount - profMem, annotation) + + -- Usually keeping count of the memory used by jprof is easy, but when realtime profiling is used + -- netFlush also frees memory for garbage collection, which might happen at unknown points in time + -- therefore the memory measured is slightly less accurate when realtime profiling is used + -- if the full profiling data is not saved to profData, then only netBuffer will increase the + -- memory used by jprof and all of it will be freed for garbage collection at some point, so that + -- we should probably not try to keep track of it at all + if profData then + profMem = profMem + (collectgarbage("count") - memCount) + end end function profiler.pop(name) @@ -73,24 +96,89 @@ if PROF_CAPTURE then assert(zoneStack[#zoneStack] == name, ("(jprof) Top of zone stack, does not match the zone passed to prof.pop ('%s', on top: '%s')!"):format(name, zoneStack[#zoneStack])) end - local preCount = collectgarbage("count") - profMem + + local memCount = collectgarbage("count") table.remove(zoneStack) - table.insert(profData, {"pop", love.timer.getTime(), preCount}) - profMem = profMem + ((collectgarbage("count") - profMem) - preCount) + addEvent("pop", memCount - profMem) + if profiler.socket and #zoneStack == 0 then + profiler.netFlush() + end + if profData then + profMem = profMem + (collectgarbage("count") - memCount) + end end function profiler.write(filename) assert(#zoneStack == 0, "(jprof) Zone stack is not empty") - local file, msg = love.filesystem.newFile(filename, "w") - assert(file, msg) - msgpackListIntoFile(profData, file) - file:close() + if not profData then + print("(jprof) No profiling data saved (probably because you called prof.connect())") + else + local file, msg = love.filesystem.newFile(filename, "w") + assert(file, msg) + msgpackListIntoFile(profData, file) + file:close() + print(("(jprof) Saved profiling data to '%s'"):format(filename)) + end end function profiler.enabled(enabled) profEnabled = enabled end + + function profiler.connect(saveFullProfData, port, address) + local socket = require("socket") + + local sock, err = socket.tcp() + if sock then + profiler.socket = sock + else + print("(jprof) Could not create socket:", err) + return + end + + local status = profiler.socket:setoption("tcp-nodelay", true) + if not status then + print("(jprof) Could not set socket option.") + end + + local status, err = profiler.socket:connect(address or "localhost", port or 1338) + if status then + print("(jprof) Connected to viewer.") + else + print("(jprof) Error connecting to viewer:", err) + profiler.socket = nil + return + end + + netBuffer = {} + if not saveFullProfData then + profData = nil + end + end + + function profiler.netFlush() + if profiler.socket and #netBuffer > 0 then + -- This should be small enough to not make trouble + -- (nothing like msgpackListIntoFile needed) + local data = msgpack.pack(netBuffer) + local len = data:len() + assert(len < 0xFFffFFff) + local header = string.char(getByte(len, 3), getByte(len, 2), getByte(len, 1), getByte(len, 0)) + local num, err = profiler.socket:send(header .. data) + if not num then + if err == "closed" then + print("(jprof) Connection to viewer closed.") + profiler.socket = nil + netBuffer = nil + return + else + print("(jprof) Error sending data:", err) + end + end + netBuffer = {} + end + end else local noop = function() end @@ -98,6 +186,8 @@ else profiler.pop = noop profiler.write = noop profiler.enabled = noop + profiler.connect = noop + profiler.netFlush = noop end -return profiler \ No newline at end of file +return profiler diff --git a/main.lua b/main.lua index 1220e97..a573f57 100644 --- a/main.lua +++ b/main.lua @@ -1,156 +1,156 @@ local lg = love.graphics local msgpack = require "MessagePack" +PROF_CAPTURE = false +prof = require("jprof") +prof.enabled(false) + local draw = require("draw") local getFrameAverage = require("frameAverage") local util = require("util") +local const = require("const") +local frames = require("frames") -local function buildGraph(data) - local frames = {} - local nodeStack = {} - for _, event in ipairs(data) do - local name, time, memory, annotation = unpack(event) - local top = nodeStack[#nodeStack] - if name ~= "pop" then - local node = { - name = name, - startTime = time, - memoryStart = memory, - annotation = annotation, - children = {}, - } - - if name == "frame" then - if #nodeStack > 0 then - error("Profiling data malformed: Pushed a new frame when the last one was not popped yet!") - end - - node.index = #frames + 1 - table.insert(frames, node) - else - if not top then - error("Profiling data malformed: Pushed a profiling zone without a 'frame' profiling zone on the stack!") - end - - table.insert(top.children, node) - end - - table.insert(nodeStack, node) - else - if not top then - error("Profiling data malformed: Popped a profiling zone on an empty stack!") - end - - top.endTime = time + 1e-8 - top.deltaTime = top.endTime - top.startTime - top.memoryEnd = memory - top.memoryDelta = top.memoryEnd - top.memoryStart - table.remove(nodeStack) - end - end - return frames -end - -local function getRange(frames, property, cutoffPercent, cutoffMin) - local values = {} - for _, frame in ipairs(frames) do - table.insert(values, frame[property]) - end - table.sort(values) - - local margin = 0 - if cutoffPercent then - assert(cutoffMin) - -- cut off the lowest and highest cutoffPercent of the values - margin = math.max(cutoffMin, math.floor(cutoffPercent * #values)) - end - return values[1 + margin], values[#values - margin] -end +local netMsgBuffer = "" +local netChannel = love.thread.newChannel() function love.load(arg) local identity, filename = arg[1], arg[2] - if not identity or not filename then - error("Usage: love jprofViewer ") - end - - love.filesystem.setIdentity(identity) - local fileData, msg = love.filesystem.read(filename) - assert(fileData, msg) - local data = msgpack.unpack(fileData) - - frames = buildGraph(data) - if #frames == 0 then - error("Frame count in the capture is zero!") + if identity == "listen" then + print("Waiting for connection...") + local netThread = love.thread.newThread("networkThread.lua") + netThread:start(netChannel, arg[2] and tonumber(arg[2]) or const.defaultPort) + elseif not identity or not filename then + print("Usage: love jprof \nor: love jprof listen [port]") + love.event.quit() + return + else + love.filesystem.setIdentity(identity) + local fileData, msg = love.filesystem.read(filename) + assert(fileData, msg) + local data = msgpack.unpack(fileData) + frames.addFrames(data) + draw.updateGraphs() + + if #frames == 0 then + error("Frame count in the capture is zero!") + end end - frames.minDeltaTime, frames.maxDeltaTime = getRange(frames, "deltaTime", 0.005, 5) - frames.minMemUsage, frames.maxMemUsage = getRange(frames, "memoryEnd") - - frames.current = frames[1] - - flameGraphType = "time" -- so far: "time" or "memory" - -- some löve things lg.setLineJoin("none") -- lines freak out otherwise lg.setLineStyle("rough") -- lines are patchy otherwise love.keyboard.setKeyRepeat(true) love.window.maximize() +end - -- setup graphs - graphs = { - mem = {}, - time = {}, - } - - for i, frame in ipairs(frames) do - local x = (i - 1) / (#frames - 1) - graphs.mem[#graphs.mem+1] = x - graphs.mem[#graphs.mem+1] = util.clamp(frame.memoryEnd / frames.maxMemUsage) - - graphs.time[#graphs.time+1] = x - graphs.time[#graphs.time+1] = util.clamp(frame.deltaTime / frames.maxDeltaTime) - end +local function lrDown(key) + return love.keyboard.isDown("l" .. key) or love.keyboard.isDown("r" .. key) end function love.keypressed(key) - local ctrl = love.keyboard.isDown("lctrl") or love.keyboard.isDown("rctrl") - local delta = ctrl and 100 or 1 - if key == "left" then - if frames.current.index then -- average frames don't have .index - frames.current = frames[math.max(1, frames.current.index - delta)] - end - elseif key == "right" then - if frames.current.index then -- average frames don't have .index - frames.current = frames[math.min(#frames, frames.current.index + delta)] + local delta = lrDown("ctrl") and 100 or 1 + if frames.current then + if key == "left" then + if frames.current.index then -- average frames don't have .index + frames.current = frames[math.max(1, frames.current.index - delta)] + end + elseif key == "right" then + if frames.current.index then -- average frames don't have .index + frames.current = frames[math.min(#frames, frames.current.index + delta)] + end end end if key == "space" then - flameGraphType = flameGraphType == "time" and "memory" or "time" + draw.flameGraphType = draw.flameGraphType == "time" and "memory" or "time" + end + + if key == "lalt" or key == "ralt" then + draw.graphMean = draw.nextGraphMean[draw.graphMean] + draw.notice("graph mean: " .. draw.graphMean) + end +end + +local function pickFrameIndex(x) + return math.floor(x / lg.getWidth() * #frames) + 1 +end + +function love.mousepressed(x, y, button) + if button == 2 and y < select(1, draw.getGraphCoords()) then + draw.popRootPath() end end -local function pickFrame(x) - return frames[math.floor(x / lg.getWidth() * #frames) + 1] +function love.resize() + draw.updateGraphs() +end + +function love.quit() + prof.write("prof.mpack") +end + +local function peekHeader(msgBuffer) + local a, b, c, d = msgBuffer:byte(1, 4) + return d + 0x100 * (c + 0x100 * (b + 0x100 * a)) +end + +local function processMessage(msgBuffer, msgLen) + prof.push("processMessage") + local headerLen = 4 + local msg = msgBuffer:sub(headerLen+1, headerLen+msgLen) + local data = msgpack.unpack(msg) + frames.addFrames(data) + prof.pop("processMessage") end function love.update() - local shift = love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") + prof.enabled(true) + prof.push("frame") + prof.push("love.update") local x, y = love.mouse.getPosition() if love.mouse.isDown(1) and y > select(1, draw.getGraphCoords()) then - local frame = pickFrame(x) - if shift then + local frameIndex = pickFrameIndex(x) + if lrDown("shift") then if frames.current.index then - frames.current = getFrameAverage(frames.current, frame) + frames.current = getFrameAverage(frames, frames.current.index, frameIndex) else - if frame.index > frames.current.fromIndex then - frames.current = getFrameAverage(frames[frames.current.fromIndex], frame) + if frameIndex > frames.current.fromIndex then + frames.current = getFrameAverage(frames, frames.current.fromIndex, frameIndex) else - frames.current = getFrameAverage(frame, frames[frames.current.toIndex]) + frames.current = getFrameAverage(frames, frameIndex, frames.current.toIndex) end end else - frames.current = frame + frames.current = frames[frameIndex] end end + + repeat + local netData = netChannel:pop() + if netData then + netMsgBuffer = netMsgBuffer .. netData + end + until netData == nil + + prof.push("read messages") + local headerLen = 4 + local updateGraphs = false + while netMsgBuffer:len() > headerLen do + local msgLen = peekHeader(netMsgBuffer) + + if netMsgBuffer:len() >= headerLen + msgLen then + processMessage(netMsgBuffer, msgLen) + netMsgBuffer = netMsgBuffer:sub(headerLen+msgLen+1) + updateGraphs = true + else + break + end + end + prof.pop("read messages") + + if updateGraphs or love.keyboard.isDown("u") then + draw.updateGraphs() + end + prof.pop("love.update") end diff --git a/networkThread.lua b/networkThread.lua new file mode 100644 index 0000000..cac1344 --- /dev/null +++ b/networkThread.lua @@ -0,0 +1,27 @@ +local socket = require("socket") + +local channel, port = ... + +local server = assert(socket.bind("*", port)) +print("Host", server:getsockname()) + +local client, err = server:accept() +print("client", client) +if not client then + print("Error accepting connection:", err) + return +end + +while true do + local data, err = client:receive(256) + if err then + if err == "closed" then + print("Connection closed.") + break + else + print("Error receiving data:", err) + end + end + channel:push(data) +end +client:close() diff --git a/util.lua b/util.lua index a8b8574..6eaa2e3 100644 --- a/util.lua +++ b/util.lua @@ -7,4 +7,86 @@ function util.clamp(x, lo, hi) return math.min(hi, math.max(lo, x)) end +util.mean = {} + +util.mean.arithmetic = { + add = function(accum, value) + return (accum or 0) + value + end, + mean = function(accum, n) + return accum and accum / n + end, +} + +util.mean.max = { + add = function(accum, value) + return accum and math.max(accum, value) or value + end, + mean = function(accum, n) + return accum + end, +} + +util.mean.min = { + add = function(accum, value) + return accum and math.min(accum, value) or value + end, + mean = function(accum, n) + return accum + end, +} + +util.mean.quadratic = { + add = function(accum, value) + return (accum or 0) + value*value + end, + mean = function(accum, n) + return accum and math.sqrt(accum / n) + end, +} + +util.mean.harmonic = { + add = function(accum, value) + return (accum or 0) + 1 / value + end, + mean = function(accum, n) + return accum and n / accum + end, +} + +function util.nodePathToStr(nodePath) + local str = "" + for _, part in ipairs(nodePath) do + str = str .. "/" .. part[1] + if part[2] > 1 then + str = str .. "[" .. part[2] .. "]" + end + end + return str:len() > 0 and str or "/" +end + +function util.getNodeByPath(frame, path) + if frame.pathCache[path] then + return frame.pathCache[path] + end + + local current = frame + for _, part in ipairs(path) do + local found = false + for _, child in ipairs(current.children) do + local childPart = child.path[#child.path] + if part[1] == childPart[1] and part[2] == childPart[2] then + current = child + found = true + break + end + end + if not found then + return nil + end + end + frame.pathCache[path] = current + return current +end + return util