Thanks for the very good question, @enderland.
I modified your profile method a little bit to better suit my needs and am sharing it here in case it helps anyone else.
There are 3 key changes that I made:
1) This code will work cross-platform (Windows and Mac) by implementing a custom GetTickCount() method (the default API is Windows only), as well as a custom dictionary class, Dict.
2) The option to write the log output to a debug worksheet instead of Debug.Print(), since the latter is limited to the number of lines that can be printed without overflow.
3) The logging handles nesting of function calls differently than OP:
Instead of seeing output like this:
ParentFunction was called 1 times for a total time of 21
ChildFunction was called 2 times for a total time of 20
The output looks like this:
ParentFunction (
ChildFunction (
) 00:00:10.000
ChildFunction (
) 00:00:10.000
) 00:00:21.000
Following is the library code...
CodeProfilerManager Class
Option Explicit
' Class - CodeProfilerManager
' https://codereview.stackexchange.com/q/70247
Private priv_profile_indexes_dict As Dict
Private Const priv_profiles_array_size As Integer = 100
Private priv_profiles_array_count As Long
Private priv_profiles_array() As CodeProfile
Private priv_running_count As Long
Private priv_history_chain As String
Private Sub Class_Initialize()
Call Reset
End Sub
Public Sub Reset()
Set priv_profile_indexes_dict = New Dict
priv_profiles_array_count = 0
ReDim priv_profiles_array(priv_profiles_array_size)
priv_running_count = 0
priv_history_chain = vbNullString
End Sub
Public Function ProfileThis(ByVal profile_id As String) As CodeProfileThis
Set ProfileThis = New CodeProfileThis
ProfileThis.manager_index = priv_profiles_array_count
' Add index to dict
If Not priv_profile_indexes_dict.HasKey(profile_id) Then
Call priv_profile_indexes_dict.Add(New collection, profile_id)
End If
Call priv_profile_indexes_dict.Item(profile_id).Add(priv_profiles_array_count)
' Set CodeProfile in array
If (priv_profiles_array_count > UBound(priv_profiles_array)) Then
ReDim Preserve priv_profiles_array(UBound(priv_profiles_array) + priv_profiles_array_size)
End If
Dim profile As New CodeProfile
profile.id = profile_id
Set priv_profiles_array(priv_profiles_array_count) = profile
' Open history chain
priv_history_chain = priv_history_chain & String(priv_running_count, vbTab) & profile_id & " (" & vbCrLf
' Increment counts
priv_profiles_array_count = priv_profiles_array_count + 1
priv_running_count = priv_running_count + 1
End Function
Public Sub ProfileEnd(ByRef profile_this As CodeProfileThis)
' This function should never be called except by CodeProfileThis.Class_Terminate()
' Update profile
Dim profile As CodeProfile
Set profile = priv_profiles_array(profile_this.manager_index)
profile.ticks_end = globals.GetTickCount()
profile.is_running = False
' Close history chain
priv_running_count = priv_running_count - 1
priv_history_chain = priv_history_chain & String(priv_running_count, vbTab) & ") " & TicksToTimeString(profile.ticks_elapsed) & vbCrLf
End Sub
Public Sub PrintHistory()
Debug.Print priv_history_chain
End Sub
Public Sub WriteHistory()
If (priv_history_chain <> vbNullString) Then
' Split history on newline char and replace tabs with 4xSpaces
Dim history_split() As String
history_split = Split(Replace$(priv_history_chain, vbTab, " "), vbCrLf)
' Write the history
Call WriteTextToDebugSheet("Code Profile", history_split)
End If
End Sub
CodeProfile Class
Option Explicit
' Class - CodeProfile
' You should never use this class directly!
' Use globals.code_profile_manager.ProfileThis()
Private priv_id As String
Private priv_is_running As Boolean
Private priv_ticks_start As Long
Private priv_ticks_end As Long
Private Sub Class_Initialize()
priv_ticks_start = globals.GetTickCount()
priv_is_running = True
End Sub
Public Property Let id(id As String)
priv_id = id
End Property
Public Property Get id() As String
id = priv_id
End Property
Public Property Let is_running(ByVal true_or_false As Boolean)
priv_is_running = true_or_false
End Property
Public Property Get is_running() As Boolean
is_running = priv_is_running
End Property
Public Property Let ticks_end(ByVal ticks As Long)
priv_ticks_end = ticks
End Property
Public Property Get ticks_end() As Long
ticks_end = priv_ticks_end
End Property
Public Property Get ticks_start() As Long
ticks_start = priv_ticks_start
End Property
Public Property Get ticks_elapsed() As Long
ticks_elapsed = priv_ticks_end - priv_ticks_start
End Property
CodeProfileThis Class
Option Explicit
' Class - CodeProfileThis
' You should never use this class directly!
' Use globals.code_profile_manager.ProfileThis()
Private priv_manager_index As Long
Public Property Let manager_index(ByVal i As Long)
priv_manager_index = i
End Property
Public Property Get manager_index() As Long
manager_index = priv_manager_index
End Property
Private Sub Class_Terminate()
Call globals.code_profile_manager.ProfileEnd(Me)
End Sub
Dict Class
Option Explicit
' Class - Dict
Private priv_keys As New collection
Private priv_values As New collection
Public Property Get Keys() As collection
Set Keys = priv_keys
End Property
Public Property Get Values() As collection
Set Values = priv_values
End Property
Public Sub Add( _
ByVal val As Variant, _
ByVal key As String _
)
Call priv_values.Add(val, key)
Call priv_keys.Add(key)
End Sub
Public Function Item(ByVal key As String) As Variant
Call SetThisToThat(Item, priv_values.Item(key))
End Function
Public Function HasKey(ByVal key As String) As Boolean
HasKey = CollectionHasKey(priv_values, key)
End Function
Public Property Get Count() As Integer
Count = priv_keys.Count
End Property
Public Sub Remove(ByVal key As String)
Dim n As Long
n = GetIndexOfCollectionValue(priv_keys, key)
Call priv_values.Remove(key)
Call priv_keys.Remove(n)
End Sub
Public Function Pop(ByVal key As String) As Variant
Dim n As Long
n = GetIndexOfCollectionValue(priv_keys, key)
Call SetThisToThat( _
Pop, _
priv_values.Item(key) _
)
Call priv_values.Remove(key)
Call priv_keys.Remove(n)
End Function
GlobalsClass Class
Option Explicit
' Class - GlobalsClass
Private Const priv_is_debug_mode As Boolean = True
Private Const priv_debug_sheet_name As String = "Debug"
Private priv_start_datetime As Double ' store as double
Private priv_code_profile_manager As New CodeProfileManager
Private Sub Class_Initialize()
priv_start_datetime = Evaluate("Now()")
End Sub
Public Function GetTickCount() As Long
' returns number of milliseconds since priv_start_datetime
'
' similar to API GetTickCount, but works on both Windows and Mac
' https://docs.microsoft.com/en-us/windows/desktop/api/sysinfoapi/nf-sysinfoapi-gettickcount
'
' the difference is that the API returns number of milliseconds since boot,
' but this function returns number of milliseconds since this class was initialized
GetTickCount = CLng( _
(Evaluate("Now()") - priv_start_datetime) * 86400000 _
)
End Function
Public Property Get code_profile_manager() As CodeProfileManager
Set code_profile_manager = priv_code_profile_manager
End Property
Public Property Get is_debug_mode() As Boolean
is_debug_mode = priv_is_debug_mode
End Property
Public Property Get debug_sheet_name() As String
debug_sheet_name = priv_debug_sheet_name
End Property
Main Module
Option Explicit
' Module - Main
Public globals As New GlobalsClass
Sub WriteCodeProfileHistory()
Call globals.code_profile_manager.WriteHistory
End Sub
Public Function TicksToTimeString(ByVal milliseconds As Long) As String
' converts milliseconds to "human-readable" format of
' hh:mm:ss.mmm
Dim hours As Long
Dim minutes As Long
Dim seconds As Long
hours = milliseconds \ 3600000
milliseconds = milliseconds - hours * 3600000
minutes = milliseconds \ 60000
milliseconds = milliseconds - minutes * 60000
seconds = milliseconds \ 1000
milliseconds = milliseconds - seconds * 1000
If (hours >= 10) Then
TicksToTimeString = hours
Else
TicksToTimeString = "0" & hours
End If
TicksToTimeString = _
TicksToTimeString & ":" & _
Right$("0" & minutes, 2) & ":" & _
Right$("0" & seconds, 2) & "." & _
Right$("00" & milliseconds, 3)
End Function
Sub SetThisToThat(ByRef this As Variant, ByVal that As Variant)
' Used if "that" can be an object or a primitive
If IsObject(that) Then
Set this = that
Else
this = that
End If
End Sub
Function GetIndexOfCollectionValue( _
ByVal c As collection, _
ByVal val As Variant _
) As Long
Dim n As Long
For n = 1 To c.Count
If (c.Item(n) = val) Then
Exit For
End If
Next n
If (n > c.Count) Then
err.Raise 5, _
"GetIndexOfCollectionValue", _
"There is no value of " & val
End If
GetIndexOfCollectionValue = n
End Function
Function CollectionHasKey( _
ByVal c As collection, _
ByVal key As String _
) As Boolean
CollectionHasKey = True
On Error GoTo no
Call IsObject(c.Item(key))
Exit Function
no:
CollectionHasKey = False
End Function
Public Function SheetExists(ByVal sheet_name As String) As Boolean
' https://stackoverflow.com/a/6040390
SheetExists = True
On Error GoTo no
Call IsObject(ActiveWorkbook.Sheets(sheet_name))
Exit Function
no:
SheetExists = False
End Function
Private Function GetDebugSheet() As Worksheet
If SheetExists(globals.debug_sheet_name) Then
Set GetDebugSheet = ActiveWorkbook.Sheets(globals.debug_sheet_name)
Else
Dim active_sheet As Worksheet
Set active_sheet = ActiveWorkbook.ActiveSheet
Set GetDebugSheet = ActiveWorkbook.Worksheets.Add( _
Before:=globals.base_workbook.Sheets(1) _
)
GetDebugSheet.name = globals.debug_sheet_name
Call active_sheet.Activate
End If
End Function
Sub WriteTextToDebugSheet( _
ByVal column_title As String, _
ByRef text_array() As String _
)
' Looks for "column_title" text in row 1 of globals.debug_sheet_name
' If found, write array to column
' Else, write to new column with "column_title"
Dim debug_sheet As Worksheet
Set debug_sheet = GetDebugSheet()
Dim header_row_i As Long
header_row_i = 1
Dim found_header_str As String
Dim target_column_i As Long
target_column_i = 1
Do While True
found_header_str = debug_sheet.Cells(header_row_i, target_column_i).Value2
If (found_header_str = column_title) Or (found_header_str = vbNullString) Then
Exit Do
End If
target_column_i = target_column_i + 1
Loop
' Set target info
Dim target_column_str As String
Dim target_row_i As Long
target_column_str = ColumnIndexAsChar(target_column_i)
target_row_i = header_row_i + 1
' Clear current contents of target column
debug_sheet.Range(target_column_str & ":" & target_column_str) _
.Value2 = vbNullString
' Update header
With debug_sheet.Cells(header_row_i, target_column_i)
.Value2 = column_title
.Font.Bold = True
.Font.Underline = xlUnderlineStyleSingle
.VerticalAlignment = xlCenter
End With
' Write text_array to target column
debug_sheet.Range( _
target_column_str & target_row_i, _
target_column_str & (target_row_i + ArrayLength(text_array) - 1) _
) _
.Value2 = WorksheetFunction.Transpose(text_array)
End Sub
Public Function ArrayLength(ByVal a As Variant) As Long
' https://stackoverflow.com/a/30574874
ArrayLength = UBound(a) - LBound(a) + 1
End Function
Example Usage:
Sub ParentFunction()
If globals.is_debug_mode Then
Dim code_profiler As CodeProfileThis
Set code_profiler = globals.code_profile_manager.ProfileThis("ParentFunction")
End If
Call ChildFunction
Call ChildFunction
Call Application.Wait(Now + TimeValue("0:00:01"))
End Sub
Sub ChildFunction()
If globals.is_debug_mode Then
Dim code_profiler As CodeProfileThis
Set code_profiler = globals.code_profile_manager.ProfileThis("ChildFunction")
End If
Call Application.Wait(Now + TimeValue("0:00:10"))
End Sub
You can fetch/view the log by one of 2 methods:
1) Call WriteCodeProfileHistory will write the log to WorkSheet globals.debug_sheet_name (and create it if it does not exist)
or
2) Call globals.code_profile_manager.PrintHistory will use Debug.Print
Method #2, Debug.Print, will work fine for the simple example of ParentFunction and ChildFunction (since the output is very small), but you will want to use #1 if the output is larger and cannot all fit into the debug window.