Be careful when using assert() and Why

Using assert() is fine, but…

TL;DR

It’s Fine if you use assert() like this:

local function printName(name)
    assert(type(name) == "string", "Argument must be a string!")
    print(name)
end

It’s Bad if you use assert() like this:

local function printName(name)
    assert(type(name) == "string", "Argument must be a string, but got: " .. tostring(name))
    print(name)
end

If you make a “custom” assert function like this, it is just the Same as the lua assert():

function(condition, errorMessage)
    if not (condition) 
        error(errorMessage, 2)
    end
end

assert() isn’t the problem but frequent string formatting is

If you use assert() a lot in your codebase, you will probably notice the script activity percentage mysteriously surge. The culprit is costly evaluation of string concatenation or string.format in the second argument for assert.

assert is a regular function. Therefore, Lua always evaluates its arguments before calling the function.

You can check the script activity percentage by enable the Script Performance view:

28956eba87933737a4eaee047c5ff32aeabe651b
image credit - @patfre

What is assert()?

Read this post if you don’t know what assert() is:

Potential Solutions

1. Use if-else block to replace assert()

Your code might look like this:

local function doSomethingWithANumber(number)
   assert(type(number) == "number", "Argument 1 must be a number, got " .. tostring(number))
end

The following code does the same thing but with better performance

local function doSomethingWithANumber(number)
   if type(number) ~= "number" then
      error("Argument 1 must be a number got " .. tostring(number))
   end
end

Explanation:
Using assert() with a second string argument is less performant because the string concatenation
"Argument 1 must be a number, got " .. tostring(number)
is always evaluated regardless of the given condition type(number) == "number".

However, if you use if-else block,
error("Argument 1 must be a number got " .. tostring(number)) will not be evaluated unless the given error condition type(number) ~= "number" is met.

Less evaluations means less code to execute, which means better performance.

Proof with Benchmarking

The result showed using if-else was about 70% more performant than using assert() .

(Feel free to point out any flaws in the test I wrote, my goal is to make the code of our games to run faster. :slight_smile: )

Code:
local function slowMaths(numberA, options)
	assert(
		type(numberA) == "number" and numberA > 0,
		"Bad Argument #1 numberA, got: " .. numberA
	)
	assert(
		type(options) == "table"
			and type(options.NumberB) == "number"
			and options.NumberB > 0,
		"Bad Argument #2 options, got: " .. tostring(options)
	)
	return numberA * options.NumberB
end

local function quickMaths(numberA, options)
	if not (type(numberA) == "number" and numberA > 0) then
		error("Bad Argument #1 numberA, got: " .. numberA)
	end

	if
		not (
			type(options) == "table"
			and type(options.NumberB) == "number"
			and options.NumberB > 0
		)
	then
		error("Bad Argument #2 options, got: " .. tostring(options))
	end

	return numberA * options.NumberB
end

local function test(testCaseText, mathFunction)
	local start = os.clock()

	for i = 1, 10000 do
		mathFunction(i, { NumberB = i + 1 })
	end

	local finish = os.clock()
	local delta = finish - start

	print(testCaseText .. " took: " .. delta .. " sec")
	return delta
end

print('Waiting for "Play Solo" to be stable.')
wait(5) -- Wait for Play Solo to be stable enough to run tests

local result1 = test("slowMaths with assert()", slowMaths)
local result2 = test("quickMaths with if-else block", quickMaths)
local fasterPercentage = ((result2 - result1) / result1 * 100)

print(
	"quickMaths with if-else block is "
		.. math.abs(fasterPercentage)
		.. "% "
		.. (fasterPercentage < 0 and "faster" or "slower")
)
Output:
Waiting for "Play Solo" to be stable.
slowMaths with assert() took: 0.022925248002139 sec
quickMaths with if-else block took: 0.0069385550013976 sec
quickMaths with if-else block is 69.734002438053% faster

2. Use Luau’s type checking syntax

local function doSomethingWithANumber(number: number)
	-- do something with a number here
end

You can read more about it here:

Ending

This is my first post, thanks for reading.

Edit:I didn’t know before I wrote this.
Apparently, someone else has already talked about this issue in a similar post. :

96 Likes

I never use assert() anyways but great post!
learned a lot

12 Likes

That’s great research to know! I will try to avoid assert()!

What about other built in mechanics like IsDescendantOf() vs FindFirstChild()?

Edit:

Ah I see EYYCHEEV

5 Likes

Both of the methods you mentioned are commonly used.

I remember there is one thing we should be careful when using FindFirstChild() but I don’t remember the specifics.

For more insights, this post might help.

2 Likes

Do you have any real-world cases where there were significant performance issues because of assert? This post just feels unnecessary, since similar to print, assert has no place in production code – it is meant for debugging.

6 Likes

assert is commonly found in many well-known third-party modules, like Promise, BitBuffer (Read more about it in this post - Stravant’s BitBuffer Module - Compact Storage Of Data [Tutorial])

A real world scenario:

BitBuffer is for the reading and manipulation of binary based data storage.

I use BitBuffer a lot to write number data as binary format so that I can potentially save tons of data to DataStore.

A method from this module, BitBuffer:WriteUnsigned() has 3 assert.

And the problem is:
I need to write about 3000 numbers using BitBuffer:WriteUnsigned() as binary format to a buffer before saving a player’s data. This operation causes a performance issue and make the server lag.

What I did:
I changed the 3 assert to 3 if-else blocks, it made this operation at least 50% faster.

assert still has its place in production code, since it might catch some subtle bugs you may not have noticed when the game is in production. When that happens, errors will help when you patch the subtle bug.

3 Likes

I disagree. If I’m making a public module, I want to throw a descriptive error if someone calls my function with invalid arguments.

7 Likes

@JarodOfOrbiter If I’m making a public module, I make it with the expectation that the user has at least a certain level of competency. I can’t account for all the possible ways of misusing it, users should generally do their own checks as well before passing in arguments.

@EYYCHEEV the production code should ideally be free of any bugs before being released (spotting them in the QA process), with the use of assert and print as helpers in the debugging process. Also won’t unnecessarily clutter the output.

Obviously arguing about where these functions are appropriate to use is beyond the scope of this topic, so those are my 2 cents.

3 Likes

You know as well as I that mistakes can be made. I am competent enough to read someone else’s source code and trace back the error to the function call, but it is undeniably nicer to have it tell me straight up so I don’t need to root through code I didn’t write. I justify it by calling it a necessary evil with this loosely typed language and this IDE created with only light scripting in mind. With C# or another language of the sort, you literally can’t send it such blatantly wrong arguments and still have it compile. On the other hand, I don’t know how well Luau’s strong-type modifications work and I could be making this argument for nothing.

13 Likes

Putting this here:

I am going to use assert just to spite you that I am not micro-optimizing a few microseconds because I care about returning the value I am asserting :grinning_face_with_smiling_eyes:, also assert has no added overhead when the statement it checks for is true and is more efficient than manually erroring something.

11 Likes

The really good thing about Luau is that they have been going hard in the paint to make sure that the most standard practices are efficient/fast.

But yeah, the fact that assert must evaluate the arguments causes the slowness. In Google’s Flogger logging library, they solve this same issue in Java by passing a closure (basically anonymous function) to the log in order to avoid evaluation unless absolutely necessary. I wonder if this would work in Lua, or if the creation of the function slows it down?

local function AssertMaybeFast(condition, func)
   if not condition then
      error(func(), 2)
   end
   return condition
end

...

AssertMaybeFast(x > 0, function() return ("x should be > 0 but it is %f"):format(x) end)

Again, I would need to benchmark that to see if there’s actually any improvement at all, or if the allocation for a new function slows it down at all.

Update: Preliminary tests show that my AssertMaybeFast is ~6x faster than the normal assert when needing to do a string format, but normal assert with just a straight literal is around 3x faster than my AssertMaybeFast version.

17 Likes

I didn’t knew the usage of the Assert() function I think it’s a very nice function but as @EYYCHEEV Said the performance gets bad and idk what happens if the performance is low and I don’t wanna know

3 Likes
function assert(c,m)
  if not c then error(m) end
end
2 Likes

that’s pretty much what an assert function already looks like. function call is the reason it’s taking that long

3 Likes

I haven’t gotten around to it yet, but this post is strangely worded and often taken out of context on top of not being the most informative. The thread there by no means is attempting to discourage the use of FindFirstChild however noting some bad habits that I’ve observed being very common.

As I delve more into programming, I’m coming more to terms with understanding why developers prefer not to use dot syntax even in cases where it’s more appropriate to do so and I intend to rewrite or completely dispel any misinformation I may be providing via that. See the forewarning for information.

5 Likes

I think the slowdown here is not just the extra evaluation but also string concatenation in Lua(considering it is slow). I think you can speed it up a bit if you short-circuit the assertion which you can do by or. or will only evaluate until it’s true, unlike and.

Though, I haven’t benchmarked this yet, this might be a better alternative to assert:

function fasterAssert(s)
   return s == true or error(s)
end

fasterAssert(true == true or "true is not " .. "true")
6 Likes

It does. Think about how “or” statements are evaluated. They won’t do extra evaluation if you already know the outcome of the entire statement. The “shortcut” does speed things up.

Here’s a good example:

local function evaluate(s, true)
      print(s)
      return true
end

if evaluate(1, true) or evaluate(2, true) then -- prints 1 only and does not evaluate 2.
     
end

if evaluate(1, false) or evaluate(2, true) or evaluate(3, true) then --prints 1 and 2 only and does not evaluate 3.
     
end

The speed up really depends on the entire cost of that evaluation.

2 Likes

If you were doing something costly in each evaluation, yes you would definitely notice a difference.

I don’t get what you mean “faster than if statements”. I am literally talking about if statements and how they are short circuited for optimizations.

You are thinking of Lua’s idiomatic way of implementing ternary operators. I’m not talking specifically about that.

It’s not false information. It’s how languages work.

2 Likes

This whole thread is about how assert must evaluate the arguments. The arguments can be costly(i.e string concatenation, string formatting). If you were to avoid that evaluation, you would see a speed up.

2 Likes

You’re still lost… i’m literally talking about if statements and how they are short circuited. I’m not talking about ternaries.

2 Likes