Skip to content

Instantly share code, notes, and snippets.

@guwidoe
Last active March 30, 2024 22:45
Show Gist options
  • Save guwidoe/5c74c64d79c0e1cd1be458b0632b279a to your computer and use it in GitHub Desktop.
Save guwidoe/5c74c64d79c0e1cd1be458b0632b279a to your computer and use it in GitHub Desktop.
Cross-platform accurate timer for VBA code
' Cross-platform VBA implementation of a high-precision timer.
' (Works on Windows and on macOS)
'
' Author: Guido Witt-Dörring
' Created: 2023/04/03
' Updated: 2023/05/16
' License: MIT
'
' ————————————————————————————————————————————————————————————————
' https://gist.github.com/guwidoe/5c74c64d79c0e1cd1be458b0632b279a
' ————————————————————————————————————————————————————————————————
'
' Copyright (c) 2023 Guido Witt-Dörring
'
' MIT License:
' Permission is hereby granted, free of charge, to any person obtaining a copy
' of this software and associated documentation files (the "Software"), to
' deal in the Software without restriction, including without limitation the
' rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
' sell copies of the Software, and to permit persons to whom the Software is
' furnished to do so, subject to the following conditions:
'
' The above copyright notice and this permission notice shall be included in
' all copies or substantial portions of the Software.
'
' THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
' IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
' FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
' AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
' LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
' FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
' IN THE SOFTWARE.
Option Explicit
#If Mac Then
#If VBA7 Then
'https://developer.apple.com/documentation/kernel/1462446-mach_absolute_time
Private Declare PtrSafe Function mach_continuous_time Lib "/usr/lib/libSystem.dylib" () As Currency
Private Declare PtrSafe Function mach_timebase_info Lib "/usr/lib/libSystem.dylib" (ByRef timebaseInfo As MachTimebaseInfo) As Long
#Else
Private Declare Function mach_continuous_time Lib "/usr/lib/libSystem.dylib" () As Currency
Private Declare Function mach_timebase_info Lib "/usr/lib/libSystem.dylib" (ByRef timebaseInfo As MachTimebaseInfo) As Long
#End If
#Else
#If VBA7 Then
'https://learn.microsoft.com/en-us/windows/win32/api/profileapi/nf-profileapi-queryperformancecounter
Private Declare PtrSafe Function QueryPerformanceFrequency Lib "kernel32" (ByRef frequency As Currency) As LongPtr
Private Declare PtrSafe Function QueryPerformanceCounter Lib "kernel32" (ByRef counter As Currency) As LongPtr
#Else
Private Declare Function QueryPerformanceFrequency Lib "kernel32" (ByRef Frequency As Currency) As Long
Private Declare Function QueryPerformanceCounter Lib "kernel32" (ByRef Counter As Currency) As Long
#End If
#End If
#If Mac Then
Private Type MachTimebaseInfo
Numerator As Long
Denominator As Long
End Type
#End If
Public Enum TimeUnit
tuSeconds = 0
tuMilliseconds
tuMicroseconds
tuAutomatic
End Enum
'Returns operating system clock tick count since system startup
Private Function GetTickCount() As Currency
#If Mac Then
GetTickCount = mach_continuous_time()
#Else
QueryPerformanceCounter GetTickCount
#End If
End Function
'Returns frequency in ticks per second
Private Function GetFrequency() As Currency
Static isInitialized As Boolean
Static freqency As Currency
If Not isInitialized Then
#If Mac Then
Dim tbInfo As MachTimebaseInfo: mach_timebase_info tbInfo
freqency = (tbInfo.Denominator / tbInfo.Numerator) * 100000@
#Else
QueryPerformanceFrequency freqency
#End If
isInitialized = True
End If
GetFrequency = freqency
End Function
'Returns time since system startup in seconds with 0.1ms (=100µs) precision
Public Function AccurateTimer(Optional ByVal unit As TimeUnit = tuSeconds) _
As Currency
Select Case unit
Case tuMicroseconds: AccurateTimer = AccurateTimerUs
Case tuMilliseconds, tuAutomatic: AccurateTimer = AccurateTimerMs
Case tuSeconds: AccurateTimer = AccurateTimerS
End Select
End Function
'Returns time since system startup in seconds with 0.1ms (=100µs) precision
Public Function AccurateTimerS() As Currency
AccurateTimerS = GetTickCount / GetFrequency
End Function
'Returns time since system startup in milliseconds with 0.1µs (=100ns) precision
Public Function AccurateTimerMs() As Currency
'Note that this calculation will work even if 1000@ / GetFrequency < 0.0001
AccurateTimerMs = (1000@ / GetFrequency) * GetTickCount
End Function
'Returns time since system startup in microseconds, up to 0.1ns =100ps precision
'The highest precision achieved by this function depends on the system, however,
'typically precision will be the same as for AccurateTimerMs.
Public Function AccurateTimerUs() As Currency
AccurateTimerUs = (1000000@ / GetFrequency) * GetTickCount
End Function
'Starts/resets a timer in the background
Public Sub StartTimer(Optional ByVal printHeaders As Boolean = True)
TimerBackend 0, printHeaders
End Sub
Public Sub st(Optional ByVal printHeaders As Boolean = True)
TimerBackend 0, printHeaders
End Sub
'Resets/starts the timer in the background, alias for StartTimer
Public Sub ResetTimer(Optional ByVal printHeaders As Boolean = False)
TimerBackend 0, printHeaders
End Sub
'Seconds to Microseconds conversion function for convenient checking against
'return values of 'ReadTimer'
Public Function StoUs(ByRef s As Currency) As Currency
StoUs = s * 1000000
End Function
'Prints the time that has passed since the last `StartTimer` or `ResetTimer`
'has been called to the immediate window and the `description` next to it.
'This sub by default subtracts its own runtime from the current timers total
'time to avoid skewing the timing results of profiled code. If that is not
'desired, i.e. for other applications than code profiling, call it with
'`subtractOwnRuntime = False`
'If 'unit = tuAutomatic', the return value is always in µs
Public Function ReadTimer(Optional ByRef description As String = vbNullString, _
Optional ByVal unit As TimeUnit = tuAutomatic, _
Optional ByVal reset As Boolean = False, _
Optional ByVal subtractOwnRuntime As Boolean = True, _
Optional ByVal printResult As Boolean = True) _
As Currency
ReadTimer = TimerBackend(1, description, unit, reset, subtractOwnRuntime, _
printResult)
End Function
Public Function RT(Optional ByRef description As String = vbNullString, _
Optional ByVal unit As TimeUnit = tuAutomatic, _
Optional ByVal reset As Boolean = False, _
Optional ByVal subtractOwnRuntime As Boolean = True, _
Optional ByVal printResult As Boolean = True) _
As Currency
RT = TimerBackend(1, description, unit, reset, subtractOwnRuntime, _
printResult)
End Function
Private Function TimerBackend(ByVal command As Long, _
ParamArray arr() As Variant) As Currency
Static timeStamp As Currency
Static callsSinceReset As Long
'Always do this first for maximum accuracy
Dim timeAtCall As Currency: timeAtCall = GetTickCount
Dim readTimeUs As Currency: readTimeUs = (1000000@ / GetFrequency) * _
(timeAtCall - timeStamp)
Select Case command
Case 0 'StartTimer or ResetTimer
If arr(0) Then 'if printHeaders ...
Debug.Print "Time taken", "Task description"
End If
callsSinceReset = 0
timeStamp = GetTickCount
Case 1 'ReadTimer
callsSinceReset = callsSinceReset + 1
Dim description As String: description = arr(0)
If description = "" Then description = "Task " & callsSinceReset
Dim unit As TimeUnit: unit = arr(1)
If unit = tuAutomatic Then
Select Case readTimeUs
Case Is > 1000000: unit = tuSeconds
Case 1000 To 1000000: unit = tuMilliseconds
Case Else: unit = tuMicroseconds
End Select
End If
Select Case unit 'Unit
Case TimeUnit.tuSeconds: TimerBackend = readTimeUs / 1000000@
Case TimeUnit.tuMilliseconds: TimerBackend = readTimeUs / 1000@
Case TimeUnit.tuMicroseconds: TimerBackend = readTimeUs
End Select
If arr(4) Then 'If printResult ...
Debug.Print TimerBackend & Choose(unit + 1, " s", " ms", _
IIf("µ" = Chr$(181), " µs", " us")), description
End If
'If unit was tuAutomatic, override return unit to ensure consistency
If arr(1) Then TimerBackend = readTimeUs
If arr(2) Then 'If reset ...
callsSinceReset = 0
timeStamp = GetTickCount
Else
'Subtract runtime of this method from future `ReadTimer` calls
If arr(3) Then _
timeStamp = timeStamp + (GetTickCount - timeAtCall)
End If
End Select
End Function
'———————————————————————————————————————————————————————————————————————————————
' DEMO PART
'———————————————————————————————————————————————————————————————————————————————
'This demonstrates the simplest, and the recommended way the procedures provided
'in this module can be used to profile your code:
Sub DemoCodeExecutionTiming()
StartTimer
'Some code that does something, e.g.:
Dim i As Long
For i = 1 To 100000
Next i
ReadTimer "Looping 100000 times." 'The desctiption is optional
End Sub
'This is a way of using the provided `AccurateTimer` functions to time things
'in a way that mimics how the built in `Timer` function is commonly used
Private Sub DemoAccurateTimer()
Dim s As Currency: s = AccurateTimerS 'or: AccurateTimer(tuSeconds)
Dim ms As Currency: ms = AccurateTimerMs 'or: AccurateTimer(tuMilliseconds)
Dim µs As Currency: µs = AccurateTimerUs 'or: AccurateTimer(tuMicroseconds)
Dim i As Long
For i = 1 To 10000000
i = i
Next i
Debug.Print "Code execution took " & AccurateTimerS - s & " seconds."
Debug.Print "Code execution plus time of the first 'Debug.Print' statement: " _
& AccurateTimerMs - ms & " milliseconds."
Debug.Print "Code execution plus time of the first two 'Debug.Print' " & _
"statements: " & AccurateTimerUs - µs & " microseconds."
End Sub
'———————————————————————————————————————————————————————————————————————————————
' SYSTEM SPECIFIC PERFORMANCE TESTING AND EXPLANATORY DEMONSTRATIONS
'———————————————————————————————————————————————————————————————————————————————
'This sub runs all the following subs, the results are printed to the immediate
'window
Sub RunAll()
ShowAverageDelayInTimingCausedByReadTimerCall
DemoSubtractOwnRuntime
ShowPrecisionOfTimersOnCurrentSystem
End Sub
'Even though `ReadTimer` by default subtracts its own runtime from the total
'time, a tiny overhead caused by the (API) function calls themselves does occur.
'This Sub demonstrated the various delays in the timing data on your own system,
'depending on how `ReadTimer` is called.
Sub ShowAverageDelayInTimingCausedByReadTimerCall()
Const LOOP_COUNT As Long = 100000
Dim i As Long
StartTimer
For i = 1 To LOOP_COUNT / 1000
ReadTimer printResult:=True, subtractOwnRuntime:=False
Next i
Debug.Print "Average delay in timing data caused by ReadTimer calls: " & _
vbNewLine & "If `subtractOwnRuntime = False` and `printResult:=True`: " & _
ReadTimer(printResult:=False) / (LOOP_COUNT / 1000) & " microseconds"
ResetTimer
For i = 1 To LOOP_COUNT / 10
ReadTimer printResult:=False, subtractOwnRuntime:=False
Next i
Debug.Print "If `subtractOwnRuntime = False` and `printResult:=False`: " & _
ReadTimer(printResult:=False) / (LOOP_COUNT / 10) & " microseconds"
ResetTimer
For i = 1 To LOOP_COUNT
ReadTimer printResult:=False
Next i
Debug.Print "If `subtractOwnRuntime = True`, regardless of `printResult`: " _
& ReadTimer(printResult:=False) / LOOP_COUNT & " microseconds"
End Sub
'This Sub demonstrates why the `subtractOwnRuntime` feature is useful.
Private Sub DemoSubtractOwnRuntime()
Const LOOP_COUNT As Long = 100000
Dim loopTimeTaken As Currency
Debug.Print vbNewLine & "Some simple code timing:" & vbNewLine
StartTimer
Dim i As Long
For i = 1 To LOOP_COUNT
Next i
loopTimeTaken = ReadTimer(printResult:=False)
ReadTimer "Time to loop " & LOOP_COUNT & " times."
ReadTimer "in seconds", tuSeconds
ReadTimer "in milliseconds", tuMilliseconds
ReadTimer "in microseconds", tuMicroseconds
Debug.Print ""
Debug.Print "The reason the time values printed by `ReadTimer` are much" _
& vbNewLine & "closer together than the time a `Debug.Print` statement" & _
vbNewLine & "usually takes is, that `ReadTimer` subtracts its own runtime" _
& vbNewLine & "from the currently running timers total time."
Debug.Print "Perhaps the following illustrates why that is beneficial:"
Dim debugPrintTimeTaken As Currency
Debug.Print vbCrLf; "Test Debug.Print statements to see how long it takes:"
ResetTimer
Debug.Print "TEST"
debugPrintTimeTaken = ReadTimer("... time taken to print ""TEST""")
Debug.Print vbNewLine & "The single `Debug.Print` statement took about " & _
CCur(debugPrintTimeTaken / loopTimeTaken) & " times " & _
vbNewLine & "as long as looping " & LOOP_COUNT & " times." & vbLf
End Sub
'Prints the precisions of the above timer functions for the current system
Private Sub ShowPrecisionOfTimersOnCurrentSystem()
Dim dtp As Currency: dtp = 0.1 'DataType precision (in ms/µs/ns)
Dim F As Currency: F = GetFrequency 'Frequency
Debug.Print "Precision for timing unit 'tuSeconds' is " & _
IIf(dtp > (0.1@ / F), dtp, (0.1@ / F)) & " milliseconds."
Debug.Print "Precision for timing unit 'tuMilliseconds' is " & _
IIf(dtp > (100@ / F), dtp, (100@ / F)) & " microseconds."
Debug.Print "Precision for timing unit 'tuMicroseconds' is " & _
IIf(dtp > (100000@ / F), dtp, (100000@ / F)) & " nanoseconds."
End Sub
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment