11 Micro-optimisation Tips for Skyrim’s Scripting Language
The Papyrus scripting language of The Elder Scrolls V: Skyrim is somewhat notorious in the game’s large community of third-party mod developers and users for performance-related issues (though there are also plenty of misconceptions, and it’s not necessarily as bad as many people think). There are already many relatively well-known tips out there for useful optimisations, but in this post I will also include a few that I have not yet seen elsewhere. All of the tips in this post are based on real code that I’ve seen looking at scripts from various popular Skyrim mods.
The Papyrus Engine
Before diving into some useful optimisations that can be used in Papyrus scripts, it’s good to take a moment to get a better understanding of how Papyrus scripts are run within the game’s engine, and what implications this has on the performance of scripts.
Fixed Papyrus Budget per Frame
One of the main things to know about the engine is that the game engine has a fixed time budget that it allocates for running active Papyrus scripts on the main thread per frame. By default this budget is 1.2ms per frame, but this can (probably) be edited by tweaking a setting in the game’s .ini file (which I don’t think I would recommend doing). That is 1.2 milliseconds per frame for all synced native calls from actively running scripts in that frame combined, it’s not per script. The game engine will distribute this time a bit over all active scripts, but not let them use any more time. It prefers for scripts to take longer and get spread out over multiple frames, rather than allowing heavy scripts to spend more time and cause the game’s framerate to be lowered.
This has two implications:
- An expensive or poorly-written script, or simply a large number of actively running scripts, will never cause the game’s framerate to slow down.
- Expensive or poorly-written scripts, or simply a large number of actively running scripts, can cause other simultaneously-running scripts to become slower / less responsive. If the same time budget (per frame) needs to be spread out over more scripts, that’s less time available per script per frame (and hence fewer instructions executed).
The second point is sometimes no problem at all, but can also sometimes snowball into a large problem. It’s fine if there are occasionally a few frames where the “demand” for script-execution time exceeds the available budget, as long as there are also frames around it in which the demand is lower and some scripts can get finished. But if there is a relatively consistent high load, where new scripts with new instructions that want to get executed keep getting added faster than the game can execute them, we have a self-reinforcing problem that can snowball out of control. The execution of scripts can get delayed by very noticeable, substantial amounts.
With this in mind, I would argue that it is not just important to optimise scripts for which you care that they actually run quickly, but also optimise any other scripts (even if delays in them would be fine) to minimise how much your non-time-sensitive scripts are affecting other scripts—which themselves may be highly time-sensitive.
Fixed Budget per Frame per Script
In SSE (Skyrim’s Special Edition, the 64bit version of the game, including the later Anniversary Edition), it appears that the game engine also enforces a fixed budget per frame per script,1 in addition to the fixed budget per frame for all active scripts together as discussed above. In comparison to the original 32bit version of Skyrim, this would:
- Reduce the likelihood that a single big script slows down others.
- Not change the likelihood of a large number of simultaneously-active scripts (regardless of size) together slowing down others.
- Increase the likelihood that you’ll find your own script or function to be extremely slow if it is big (has many instructions to be executed).
Interestingly, this budget does not seem to be a time budget, but rather an instructions budget (judging by various experiments reported in the page I linked above). Hence, any simple little instruction you can shave off a script can make a difference here, regardless of whether it is an expensive instruction (like function calls, which are notoriously expensive in Papyrus), or something cheap like casting a variable to a different type.
Note on Profiling-guided Optimising & Papyrus
Normally in most programming languages, and most software, we would rather not dive into micro-optimisations without profiling our code. Profiling is indeed also possible in Papyrus, but in my opinion there are some peculiarities about working with Papyrus in Skyrim that make profiling less interesting, and just diving into the code and applying micro-optimisations more interesting:
- Real-world code (i.e., scripts in mods or in the base game) are generally very short and fast (in terms of wall time), making the accuracy of timings a bit questionable. If you artificially make them slower (e.g., by taking the same code you would realistically only want to run once, but repeating it many times in a loop) such that you actually have something to measure, you start hitting a bunch of limitations caused by the different budgets described above that you wouldn’t have hit otherwise.
- Even if something is already very fast, micro-optimisations may still be worth it because they can stop your scripts from negatively affecting other scripts. Due to the various budgets that the engine uses, there can be “sudden” effects, where even a single instruction more or less can make a full frame’s difference in terms of whether or not your script is still running.
- Different users (modded Skyrim players) have wildly different setups. Some use no mods, some use a few mods, and others use hundreds or thousands of mods. Even if a little optimisation has no noticeable impact on your setup, it may have a noticeable effect on other people’s installations.
- Papyrus scripts are usually dozens of lines, not tens of thousands. So investing a little bit of time into some micro-optimisations is usually not actually a lot of work at all.
Papyrus Micro-optimisations
Highest-impact Micro-optimisations
First, I will discuss some of the highest-impact micro-optimisations that script authors should generally look towards first. I will still only be focusing on micro-optimisations though, so not, for example, major algorithm overhauls. I will also not get into detail on some of the more well-known concerns around RegisterForUpdate() (hint: you should probably use RegisterForSingleUpdate() instead), OnHit (hint: consider using PAPER’s OnImpact event if possible), or OnMagicEffectApply (hint: consider using various events from Po3’s Papyrus Extender instead, as in the Vanilla Scripting Enhancements mod) either.
1. Avoiding Game.GetPlayer()
Let’s start with an easy one. Game.GetPlayer() is a function that returns a reference to the Player. However, function calls are slow in Papyrus (especially when they aren’t in the list of non-delayed native functions). Instead, we can simply get a reference to the Player through a property. This will always be substantially faster.
2. Avoiding abbreviated versions of functions
For many native functions, there are also abbreviated versions available that do exactly the same thing, but just have a slightly shorter function name. Unfortunately, none of these were implemented to be native functions (with different names, but mapping to the same native code) themselves. Instead, they were all implemented as single-line Papyrus functions that just call the corresponding native function with the longer name. This means that they are strictly slower (because they have additional overhead from an extra Papyrus function call), and should really just never be used. Some examples include:
- GetAV(): should call GetActorValue() instead.
- GetAVPercentage(): should call GetActorValuePercentage() instead.
- GetBaseAV(): should call GetBaseActorValue() instead.
- DamageAV(): should call DamageActorValue() instead.
- ModAV(): should call ModActorValue() instead.
- SetAV(): should call SetActorValue() instead.
- ForceAV(): should call ForceActorValue() instead.
- RestoreAV(): should call RestoreActorValue() instead.
- GetRef(): should call GetReference instead.
Closely related to the above examples, it is also good to avoid short convenience functions that only wrap what would really be a short, single line of code anyway, even if it abstracts multiple instructions. Of course, having functions with meaningful names wrapping functionality is good from the points of view of code readability and maintenance, but can be detrimental to performance due to function call overhead. For example:
- Should use
GlobalVariable.GetValue() as int
(manually adding the cast to an int) instead of the GetValueInt() convenience function. - Should use
GetReference() as Actor
(manually adding the cast to an Actor) instead of the GetActorReference() or GetActorRef() convenience functions.
Lower-impact Micro-optimisations
Next, I will look at some even lower-level, and probably lower-impact, micro-optimisations. Many of these no longer really involve avoiding Papyrus function call overhead or things that are slow due to concurrency / syncing with framerate, but they rather involve optimising away some low-level operations.
3. Do not write b == true
I often see code like this:
Function TestEqualToTrue(bool b)
If (b == true) ; Do NOT do this!
Debug.Notification("test")
EndIf
EndFunction
My issue with this code is that the comparison to true
is completely unnecessary. An if
-statement just
requires a single expression that evaluates to a bool
. The variable b
is already a bool
, so we can use
that directly: there is no need to compare it to true
(which would be a more complex expression that ultimately
just gives us the value of b
back again)! The more efficient implementation would be as follows:
Function TestTrue(bool b)
If (b) ; This is better!
Debug.Notification("test")
EndIf
EndFunction
To show that this actually makes a difference, we can use the Champollion decompiler to decompile compiled versions of scripts with those functions, and annotate the decompiled source code with assembly:
function TestEqualToTrue(Bool b)
;Bool ::temp3
;None ::NoneVar
; 000 : cmp_eq ::temp3 b true
; 001 : jmpf ::temp3 004
if b == true
; 002 : callstatic debug Notification ::NoneVar "test"
debug.Notification("test")
; 003 : jmp 004
endIf
endFunction
function TestTrue(Bool b)
;None ::NoneVar
; 000 : jmpf b 003
if b
; 001 : callstatic debug Notification ::NoneVar "test"
debug.Notification("test")
; 002 : jmp 003
endIf
endFunction
In the first version, we have to (1) run cmp_eq
to compare b
to true
, (2) put the outcome in a temporary variable
::temp3
, and (3) jump to 004
(past the debug.Notification()
call) if the comparison was false. In the second version,
we just directly jump based on the value of b
: no comparison, and no temporary variable!
4. Do not write b != true
Similar to the previous idea, I find the following code (including assembly in comments) problematic:
function TestNotEqualToTrue(Bool b)
;Bool ::temp0
;None ::NoneVar
; 000 : cmp_eq ::temp0 b true
; 001 : not ::temp0 ::temp0
; 002 : jmpf ::temp0 005
if b != true
; 003 : callstatic debug Notification ::NoneVar "test"
debug.Notification("test")
; 004 : jmp 005
endIf
endFunction
The if b != true
line compiles into three instructions: (1) A cmp_eq
operation to compare b
and true
for
equality, putting the result in a ::temp0
variable. (2) A not
operation to negate ::temp0
. (3) A jmpf
that
jumps past the debug.Notification()
call if ::temp0
is false.
Better solutions are to write either (1) if !b
, or (2) if b == false
. The first solution avoids the cmp_eq
(it only needs to negate and jump), whereas the second solution avoids the not
(it only needs to compare and jump).
5. Use the expected type for function arguments if possible
If a function in Papyrus expects parameters of a certain type, it will only take constants or variables of exactly that type. The compiler will not complain if you give it something that can, with 100% certainty, be cast into the expected type… but it will insert an extra casting operation into the compiled code. Even for constants, the compiler is not smart enough to perform these casts at compile-time.
For example, the Utility.Wait function expects
a single argument of the float
type. So, what should we write if we want our script to wait for one second?
Quite often, I see this: Utility.Wait(1)
. But if we decompile a function with this line of code in it, and
include assembly in the comments again, we get the following:
function WaitInt()
;Float ::temp4
;None ::NoneVar
; 000 : cast ::temp4 1
; 001 : callstatic utility Wait ::NoneVar ::temp4
utility.Wait(1 as Float)
endFunction
We don’t even have to look at the assembly, but can just look at the decompiled source code in the last line,
to see something interesting: the 1
constant was changed into 1 as Float
. An explicit casting operation
has been inserted, which gets executed (at runtime!) every time this line of code runs. The assembly shows
that, in practice, this does not only mean that we have the extra cast
operation, but also an extra
::temp4
variable to hold the result of the cast.
In contrast, if we simply write 1.0
instead of 1
, such that we have a constant of the proper type
(float
instead of int
), we get the following decompiled code (annotated with assembly):
function WaitFloat()
;None ::NoneVar
; 000 : callstatic utility Wait ::NoneVar 1.00000
utility.Wait(1.00000)
endFunction
In the example of Utility.Wait()
, it’s just a single parameter. But there are also many worse cases out
there, such as PlayImpactEffect()
.
This function has four different float
parameters which, in many scripts
I’ve seen out there, are all often set to int
constants. In fact, even the example line of code on the official
documentation page gets it wrong (using 0, 0, -1, 512
as arguments instead of 0.0, 0.0, -1.0, 512.0
)!
While this idea most often applies to int
and float
constants, it can also apply to other types. For example,
I’ve run into some scripts that store a reference in a variable of type Actor
, but exclusively use it as an
argument for functions that except parameters of type ObjectReference
(of which Actor
is a subclass). This,
unnecessarily, produces a runtime cast from Actor
to ObjectReference
every time it is passed into a function.
This is a rare case though, since usually, when we have an object of type Actor
, we have a mix of calls where
some really require it to be of that type, and only some expect just the ObjectReference
. In these cases, I would
simply recommend only keeping the Actor
variable around, and not worry about how it sometimes gets cast.
6. Compare variables to constants of matching type
The previous point focused on arguments passed into functions, but the same idea applies when comparing
variables to constants with (in)equality operators (i.e., <
, <=
, ==
, >=
, >
, or !=
). If you have a
float
variable that you want to compare to some constant, you should make sure to also type the constant
as a float
.
function SomeFunction(float f)
if (f > 1) ; Do NOT do this! The 1 will be cast at runtime to 1.0!
; some code here
endIf
if (f > 1.0) ; This is better!
; some code here
endIf
endFunction
7. Directly assign boolean expression to variable
Consider the following code:
bool a = False
if (some expression that resolves to a boolean result)
a = True
else
a = False
endIf
In this code, the variable a
actually always gets set to whatever result comes out of the (maybe simple, or maybe complex)
boolean expression used for the if
-condition. This means that it is wasteful to actually have the if
there at all, and
the following would be more efficient:
a = (some expression that resolves to a boolean result)
A variant of the same issue that I also sometimes see is the following:
; Some bool variable named "a" is guaranteed to be False when this code runs
if (some expression that resolves to a boolean result)
a = True
endIf
Again, the same solution as above would be better in this case. However, in this case it is really important to make sure
that the a
variable indeed is guaranteed to be False
at the time when you enter this piece of code! If there is any
chance that it might already be True
, omitting the if
-block would change what the code does.
8. Assigning negation of variable used in condition
I have seen the following construction used in some mods’ scripts:
Function AssignNegation(bool b)
If (b)
b = !b
; Some other code here too
Else
b = !b
; Some other code here too
EndIf
EndFunction
Note that it’s actually important that there’s also additional code in at least one of either the
If
- or the Else
-block, because otherwise we just have a variant of the issue previously described
issue and we shouldn’t be using If
- and Else
-blocks at all. So, let’s pretend that the blocks
are actually meaningful, and look at the decompiled code with assembly annotations in comments:
function AssignNegation(Bool b)
;Bool ::temp5
; 000 : jmpf b 004
if b
; 001 : not ::temp5 b
; 002 : assign b ::temp5
b = !b
; 003 : jmp 006
else
; 004 : not ::temp5 b
; 005 : assign b ::temp5
b = !b
endIf
endFunction
We can see that b
is not directly set to its own negation. A temporary variable ::temp5
is used
to hold the negation of b
, and then the value of that variable is assigned to b
again. But, in
each of the lines of code that perform this assignment, we actually already know (at compile-time)
what value we wish to assign! Either we are inside the If
-block (where we know b
must
be True
, and we want it to be set to False
), or we are inside the Else
-block (where we know
b
must be False
, and we want it to be set to True
). If we directly use those constants, we
get the following code, annotated with assembly that shows us how it is more efficient:
function AssignNegatedConstant(Bool b)
; 000 : jmpf b 003
if b
; 001 : assign b false
b = false
; 002 : jmp 004
else
; 003 : assign b true
b = true
endIf
endFunction
9. Avoid checking something that you already know
Consider the following code:
if b
; some code here
elseIf b == false ; Do not do this!
; some other code here
endIf
As soon as we go past the if
-block (i.e., into a subsequent elseIf
or else
), we already know
that b
must evaluate to false
. So, there is no need to verify that it is false
, and we should
rather write the following:
if b
; some code here
else
; some other code here
endIf
This is especially important in cases where b
is a more complex boolean expression (including one
or more function calls or just a longer chain of multiple boolean variables), but technically also
still relevant in the most simple case where b
is just a single variable.
10. Nest conditions instead of repeating them
Consider this code:
if a && b
; some code here
elseIf a && c
; some other code here
endIf
In cases where a
or b
are false, and we drop into checking the condition for the elseIf
line,
we will waste time testing the value of “a
” a second time, even though we just already did that for
the first if
line. It is more efficient (again, especially if a
is a more complex expression) to
rewrite this as follows:
if a
if b
; some code here
elseIf c
; some other code here
endIf
endIf
11. Factoring out value added to lower and upper bounds for Utility.RandomInt
For the final tip of this post, I have a rather specific case that I ran into looking
at various mods’ scripts. The original script included this expression (where x
was
some int
variable):
Utility.RandomInt(x + 5, x + 20)
The Utility.RandomInt()
function
takes two arguments. The first is a lower bound, and the second an upper bound. It returns a random
integer between those bounds (in contrast to most programming languages, both inclusive!). So, the
above expression simply picks a random integer between x + 5
and x + 20
. There is a slightly more
efficient way to write the same thing though:
x + Utility.RandomInt(5, 20)
If we write the code like this, we first generate a random integer between 5
and 20
, and afterwards
add x
to whichever random value was returned. This ultimately leads to a random value being drawn from
the same distribution, but it is slightly more efficient because the operation of adding x
to another
value is only executed once instead of twice.
-
Nightfallstorm’s recently-released Papyrus Tweaks NG mod can increase the number of operations that can be executed per frame, per stack. This probably makes my entire post a lot less useful (but faster is still always better!). ↩
Leave a comment