--- **Utils** - Lua Profiler. -- -- Find out how many times functions are called and how much real time it costs. -- -- === -- -- ### Author: **TAW CougarNL**, *funkyfranky* -- -- @module Utilities.PROFILER -- @image MOOSE.JPG --- PROFILER class. -- @type PROFILER -- @field #string ClassName Name of the class. -- @field #table Counters Function counters. -- @field #table dInfo Info. -- @field #table fTime Function time. -- @field #table fTimeTotal Total function time. -- @field #table eventhandler Event handler to get mission end event. -- @field #number TstartGame Game start time timer.getTime(). -- @field #number TstartOS OS real start time os.clock. -- @field #boolean logUnknown Log unknown functions. Default is off. -- @field #number ThreshCPS Low calls per second threshold. Only write output if function has more calls per second than this value. -- @field #number ThreshTtot Total time threshold. Only write output if total function CPU time is more than this value. -- @field #string fileNamePrefix Output file name prefix, e.g. "MooseProfiler". -- @field #string fileNameSuffix Output file name prefix, e.g. "txt" --- *The emperor counsels simplicity. First principles. Of each particular thing, ask: What is it in itself, in its own constitution? What is its causal nature? * -- -- === -- -- ![Banner Image](..\Presentations\Utilities\PROFILER_Main.jpg) -- -- # The PROFILER Concept -- -- Profile your lua code. This tells you, which functions are called very often and which consume most real time. -- With this information you can optimize the performance of your code. -- -- # Prerequisites -- -- The modules **os** and **lfs** need to be de-sanitized. -- -- # Start -- -- The profiler can simply be started with the @{#PROFILER.Start}(*Delay, Duration*) function -- -- PROFILER.Start() -- -- The optional parameter *Delay* can be used to delay the start by a certain amount of seconds and the optional parameter *Duration* can be used to -- stop the profiler after a certain amount of seconds. -- -- # Stop -- -- The profiler automatically stops when the mission ends. But it can be stopped any time with the @{#PROFILER.Stop}(*Delay*) function -- -- PROFILER.Stop() -- -- The optional parameter *Delay* can be used to specify a delay after which the profiler is stopped. -- -- When the profiler is stopped, the output is written to a file. -- -- # Output -- -- The profiler output is written to a file in your DCS home folder -- -- X:\User\\Saved Games\DCS OpenBeta\Logs -- -- The default file name is "MooseProfiler.txt". If that file exists, the file name is "MooseProfiler-001.txt" etc. -- -- ## Data -- -- The data in the output file provides information on the functions that were called in the mission. -- -- It will tell you how many times a function was called in total, how many times per second, how much time in total and the percentage of time. -- -- If you only want output for functions that are called more than *X* times per second, you can set -- -- PROFILER.ThreshCPS=1.5 -- -- With this setting, only functions which are called more than 1.5 times per second are displayed. The default setting is PROFILER.ThreshCPS=0.0 (no threshold). -- -- Furthermore, you can limit the output for functions that consumed a certain amount of CPU time in total by -- -- PROFILER.ThreshTtot=0.005 -- -- With this setting, which is also the default, only functions which in total used more than 5 milliseconds CPU time. -- -- @field #PROFILER PROFILER = { ClassName = "PROFILER", Counters = {}, dInfo = {}, fTime = {}, fTimeTotal = {}, eventHandler = {}, logUnknown = false, ThreshCPS = 0.0, ThreshTtot = 0.005, fileNamePrefix = "MooseProfiler", fileNameSuffix = "txt" } --- Waypoint data. -- @type PROFILER.Data -- @field #string func The function name. -- @field #string src The source file. -- @field #number line The line number -- @field #number count Number of function calls. -- @field #number tm Total time in seconds. ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Start/Stop Profiler ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- Start profiler. -- @param #number Delay Delay in seconds before profiler is stated. Default is immediately. -- @param #number Duration Duration in (game) seconds before the profiler is stopped. Default is when mission ends. function PROFILER.Start( Delay, Duration ) -- Check if os, io and lfs are available. local go = true if not os then env.error( "ERROR: Profiler needs os to be de-sanitized!" ) go = false end if not io then env.error( "ERROR: Profiler needs io to be de-sanitized!" ) go = false end if not lfs then env.error( "ERROR: Profiler needs lfs to be de-sanitized!" ) go = false end if not go then return end if Delay and Delay > 0 then BASE:ScheduleOnce( Delay, PROFILER.Start, 0, Duration ) else -- Set start time. PROFILER.TstartGame = timer.getTime() PROFILER.TstartOS = os.clock() -- Add event handler. world.addEventHandler( PROFILER.eventHandler ) -- Info in log. env.info( '############################ Profiler Started ############################' ) if Duration then env.info( string.format( "- Will be running for %d seconds", Duration ) ) else env.info( string.format( "- Will be stopped when mission ends" ) ) end env.info( string.format( "- Calls per second threshold %.3f/sec", PROFILER.ThreshCPS ) ) env.info( string.format( "- Total function time threshold %.3f sec", PROFILER.ThreshTtot ) ) env.info( string.format( "- Output file \"%s\" in your DCS log file folder", PROFILER.getfilename( PROFILER.fileNameSuffix ) ) ) env.info( string.format( "- Output file \"%s\" in CSV format", PROFILER.getfilename( "csv" ) ) ) env.info( '###############################################################################' ) -- Message on screen local duration = Duration or 600 trigger.action.outText( "### Profiler running ###", duration ) -- Set hook. debug.sethook( PROFILER.hook, "cr" ) -- Auto stop profiler. if Duration then PROFILER.Stop( Duration ) end end end --- Stop profiler. -- @param #number Delay Delay before stop in seconds. function PROFILER.Stop( Delay ) if Delay and Delay > 0 then BASE:ScheduleOnce( Delay, PROFILER.Stop ) else -- Remove hook. debug.sethook() -- Run time game. local runTimeGame = timer.getTime() - PROFILER.TstartGame -- Run time real OS. local runTimeOS = os.clock() - PROFILER.TstartOS -- Show info. PROFILER.showInfo( runTimeGame, runTimeOS ) end end --- Event handler. function PROFILER.eventHandler:onEvent( event ) if event.id == world.event.S_EVENT_MISSION_END then PROFILER.Stop() end end ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Hook ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- Debug hook. -- @param #table event Event. function PROFILER.hook( event ) local f = debug.getinfo( 2, "f" ).func if event == 'call' then if PROFILER.Counters[f] == nil then PROFILER.Counters[f] = 1 PROFILER.dInfo[f] = debug.getinfo( 2, "Sn" ) if PROFILER.fTimeTotal[f] == nil then PROFILER.fTimeTotal[f] = 0 end else PROFILER.Counters[f] = PROFILER.Counters[f] + 1 end if PROFILER.fTime[f] == nil then PROFILER.fTime[f] = os.clock() end elseif (event == 'return') then if PROFILER.fTime[f] ~= nil then PROFILER.fTimeTotal[f] = PROFILER.fTimeTotal[f] + (os.clock() - PROFILER.fTime[f]) PROFILER.fTime[f] = nil end end end ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Data ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- Get data. -- @param #function func Function. -- @return #string Function name. -- @return #string Source file name. -- @return #string Line number. -- @return #number Function time in seconds. function PROFILER.getData( func ) local n = PROFILER.dInfo[func] if n.what == "C" then return n.name, "?", "?", PROFILER.fTimeTotal[func] end return n.name, n.short_src, n.linedefined, PROFILER.fTimeTotal[func] end --- Write text to log file. -- @param #function f The file. -- @param #string txt The text. function PROFILER._flog( f, txt ) f:write( txt .. "\r\n" ) end --- Show table. -- @param #table data Data table. -- @param #function f The file. -- @param #number runTimeGame Game run time in seconds. function PROFILER.showTable( data, f, runTimeGame ) -- Loop over data. for i = 1, #data do local t = data[i] -- #PROFILER.Data -- Calls per second. local cps = t.count / runTimeGame local threshCPS = cps >= PROFILER.ThreshCPS local threshTot = t.tm >= PROFILER.ThreshTtot if threshCPS and threshTot then -- Output local text = string.format( "%30s: %8d calls %8.1f/sec - Time Total %8.3f sec (%.3f %%) %5.3f sec/call %s line %s", t.func, t.count, cps, t.tm, t.tm / runTimeGame * 100, t.tm / t.count, tostring( t.src ), tostring( t.line ) ) PROFILER._flog( f, text ) end end end --- Print csv file. -- @param #table data Data table. -- @param #number runTimeGame Game run time in seconds. function PROFILER.printCSV( data, runTimeGame ) -- Output file. local file = PROFILER.getfilename( "csv" ) local g = io.open( file, 'w' ) -- Header. local text = "Function,Total Calls,Calls per Sec,Total Time,Total in %,Sec per Call,Source File;Line Number," g:write( text .. "\r\n" ) -- Loop over data. for i = 1, #data do local t = data[i] -- #PROFILER.Data -- Calls per second. local cps = t.count / runTimeGame -- Output local txt = string.format( "%s,%d,%.1f,%.3f,%.3f,%.3f,%s,%s,", t.func, t.count, cps, t.tm, t.tm / runTimeGame * 100, t.tm / t.count, tostring( t.src ), tostring( t.line ) ) g:write( txt .. "\r\n" ) end -- Close file. g:close() end --- Write info to output file. -- @param #string ext Extension. -- @return #string File name. function PROFILER.getfilename( ext ) local dir = lfs.writedir() .. [[Logs\]] ext = ext or PROFILER.fileNameSuffix local file = dir .. PROFILER.fileNamePrefix .. "." .. ext if not UTILS.FileExists( file ) then return file end for i = 1, 999 do local file = string.format( "%s%s-%03d.%s", dir, PROFILER.fileNamePrefix, i, ext ) if not UTILS.FileExists( file ) then return file end end end --- Write info to output file. -- @param #number runTimeGame Game time in seconds. -- @param #number runTimeOS OS time in seconds. function PROFILER.showInfo( runTimeGame, runTimeOS ) -- Output file. local file = PROFILER.getfilename( PROFILER.fileNameSuffix ) local f = io.open( file, 'w' ) -- Gather data. local Ttot = 0 local Calls = 0 local t = {} local tcopy = nil -- #PROFILER.Data local tserialize = nil -- #PROFILER.Data local tforgen = nil -- #PROFILER.Data local tpairs = nil -- #PROFILER.Data for func, count in pairs( PROFILER.Counters ) do local s, src, line, tm = PROFILER.getData( func ) if PROFILER.logUnknown == true then if s == nil then s = "" end end if s ~= nil then -- Profile data. local T = { func = s, src = src, line = line, count = count, tm = tm } -- #PROFILER.Data -- Collect special cases. Somehow, e.g. "_copy" appears multiple times so we try to gather all data. if s == "_copy" then if tcopy == nil then tcopy = T else tcopy.count = tcopy.count + T.count tcopy.tm = tcopy.tm + T.tm end elseif s == "_Serialize" then if tserialize == nil then tserialize = T else tserialize.count = tserialize.count + T.count tserialize.tm = tserialize.tm + T.tm end elseif s == "(for generator)" then if tforgen == nil then tforgen = T else tforgen.count = tforgen.count + T.count tforgen.tm = tforgen.tm + T.tm end elseif s == "pairs" then if tpairs == nil then tpairs = T else tpairs.count = tpairs.count + T.count tpairs.tm = tpairs.tm + T.tm end else table.insert( t, T ) end -- Total function time. Ttot = Ttot + tm -- Total number of calls. Calls = Calls + count end end -- Add special cases. if tcopy then table.insert( t, tcopy ) end if tserialize then table.insert( t, tserialize ) end if tforgen then table.insert( t, tforgen ) end if tpairs then table.insert( t, tpairs ) end env.info( '############################ Profiler Stopped ############################' ) env.info( string.format( "* Runtime Game : %s = %d sec", UTILS.SecondsToClock( runTimeGame, true ), runTimeGame ) ) env.info( string.format( "* Runtime Real : %s = %d sec", UTILS.SecondsToClock( runTimeOS, true ), runTimeOS ) ) env.info( string.format( "* Function time : %s = %.1f sec (%.1f percent of runtime game)", UTILS.SecondsToClock( Ttot, true ), Ttot, Ttot / runTimeGame * 100 ) ) env.info( string.format( "* Total functions : %d", #t ) ) env.info( string.format( "* Total func calls : %d", Calls ) ) env.info( string.format( "* Writing to file : \"%s\"", file ) ) env.info( string.format( "* Writing to file : \"%s\"", PROFILER.getfilename( "csv" ) ) ) env.info( "##############################################################################" ) -- Sort by total time. table.sort( t, function( a, b ) return a.tm > b.tm end ) -- Write data. PROFILER._flog( f, "" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "" ) PROFILER._flog( f, "-------------------------" ) PROFILER._flog( f, "---- Profiler Report ----" ) PROFILER._flog( f, "-------------------------" ) PROFILER._flog( f, "" ) PROFILER._flog( f, string.format( "* Runtime Game : %s = %.1f sec", UTILS.SecondsToClock( runTimeGame, true ), runTimeGame ) ) PROFILER._flog( f, string.format( "* Runtime Real : %s = %.1f sec", UTILS.SecondsToClock( runTimeOS, true ), runTimeOS ) ) PROFILER._flog( f, string.format( "* Function time : %s = %.1f sec (%.1f %% of runtime game)", UTILS.SecondsToClock( Ttot, true ), Ttot, Ttot / runTimeGame * 100 ) ) PROFILER._flog( f, "" ) PROFILER._flog( f, string.format( "* Total functions = %d", #t ) ) PROFILER._flog( f, string.format( "* Total func calls = %d", Calls ) ) PROFILER._flog( f, "" ) PROFILER._flog( f, string.format( "* Calls per second threshold = %.3f/sec", PROFILER.ThreshCPS ) ) PROFILER._flog( f, string.format( "* Total func time threshold = %.3f sec", PROFILER.ThreshTtot ) ) PROFILER._flog( f, "" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "" ) PROFILER.showTable( t, f, runTimeGame ) -- Sort by number of calls. table.sort( t, function( a, b ) return a.tm / a.count > b.tm / b.count end ) -- Detailed data. PROFILER._flog( f, "" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "" ) PROFILER._flog( f, "--------------------------------------" ) PROFILER._flog( f, "---- Data Sorted by Time per Call ----" ) PROFILER._flog( f, "--------------------------------------" ) PROFILER._flog( f, "" ) PROFILER.showTable( t, f, runTimeGame ) -- Sort by number of calls. table.sort( t, function( a, b ) return a.count > b.count end ) -- Detailed data. PROFILER._flog( f, "" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "" ) PROFILER._flog( f, "------------------------------------" ) PROFILER._flog( f, "---- Data Sorted by Total Calls ----" ) PROFILER._flog( f, "------------------------------------" ) PROFILER._flog( f, "" ) PROFILER.showTable( t, f, runTimeGame ) -- Closing. PROFILER._flog( f, "" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "************************************************************************************************************************" ) PROFILER._flog( f, "************************************************************************************************************************" ) -- Close file. f:close() -- Print csv file. PROFILER.printCSV( t, runTimeGame ) end