Last active
March 1, 2025 21:46
-
-
Save guwidoe/5c74c64d79c0e1cd1be458b0632b279a to your computer and use it in GitHub Desktop.
Cross-platform accurate timer for VBA code
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
' 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