That's why you should never use the pairs and ipairs iterators in Luau

Greetings! In this tutorial, we will touch on one of the most common operations in programming — iteration over tables (arrays) in Luau.

What is pairs and ipairs?

In Luau, pairs and ipairs are functions that are used to iterate through tables. It is important to note that these functions return iterative functions, that is, they are higher-order functions. This means that pairs and ipairs do not iterate by themselves, but create a function that then iterates over the table.

1 pairs: This function is designed to traverse all key-value pairs in the table. pairs can work with any type of tables (dictionaries and arrays), which makes it very flexible. However, this flexibility comes at a price — pairs will run slower than more specialized methods.

local Table = {key1 = "value1", key2 = "value2", 10, 20}
for key, value in pairs(Table) do
    print(key, value)
end

2 ipairs: This function is designed to iterate through tables with numeric indexes starting from 1 (arrays). ipairs returns an index-value pair, which makes it seemingly suitable for arrays. However, as we will show, ipairs is not the fastest option.

local myArray = {10, 20, 30, 40}
for index, value in ipairs(myArray) do
    print(index, value)
end

Why are they needed when you can do without them?

What is the problem with pairs and ipairs? As we have already found out, pairs and ipairs are functions that return iterative functions. This means that using them entails calling a function and the associated overhead.
A direct loop for _, value in array is “nothing” (almost): When you use for _, value in array, you access the array elements directly, bypassing intermediate functions. This is not a function call, but a direct action.

Functions are always slower Any function call, even the fastest one, takes longer than direct data access. In the case of iterating over an array, this difference may be small, but if you need maximum performance, you should pay attention to such details.

Tests

Now that we’ve discussed theory, let’s back up our claims with practice. In this section, we will conduct a series of tests to compare the performance of for _, value in array, ipairs, and pairs when iterating over arrays. We will measure the average execution time for each operation and compare the results. This will allow us to clearly see the advantages of a direct cycle

Test1: exponentiation

In this test, we will create an array of numbers and square each element at each iteration using all three iteration methods.
To begin with, we will define functions for each of the iteration methods:

Exponentiation script
local function powerIterationWithFor(array)
    local result = 0
    for _, value in array do
        result = result + value^2
    end
    return result
end

local function powerIterationWithIpairs(array)
    local result = 0
    for _, value in ipairs(array) do
        result = result + value^2
    end
    return result
end

local function powerIterationWithPairs(array)
   local result = 0
   for _, value in pairs(array) do
        result = result + value^2
   end
   return result
end

Now, let’s create a test array and perform exponentiation:

local testArray = {}
for i = 1, 1000 do
	testArray[i] = i % 10 + 1
end
local numIterations = 1000

local startFor = os.clock()
local resultFor

for i = 1, numIterations do
	resultFor = powerIterationWithFor(testArray)
end

local endFor = os.clock()
local timeFor = endFor - startFor
local avgTimeFor = timeFor / numIterations

local startIpairs = os.clock()
local resultIpairs

for i = 1, numIterations do
	resultIpairs = powerIterationWithIpairs(testArray)
end

local endIpairs = os.clock()
local timeIpairs = endIpairs - startIpairs
local avgTimeIpairs = timeIpairs / numIterations

local startPairs = os.clock()
local resultPairs

for i = 1, numIterations do
	resultPairs = powerIterationWithPairs(testArray)
end

local endPairs = os.clock()
local timePairs = endPairs - startPairs
local avgTimePairs = timePairs / numIterations

print("No iterators:", avgTimeFor * 1000000)
print("ipairs:", avgTimeIpairs * 1000000)
print("pairs:", avgTimePairs * 1000000)

Well. I’ve done about 30 tests. I ran 100,000 iterations in each of them.
I came to the conclusion that ipairs is about 8% slower.
Pairs, on the other hand, turned out to be much better than my expectations. I wanted to write it off as an error, but it almost always turned out to be just a little bit slower ~ 0.7%
Average speed:

No Iterators: 6.402 Microseconds
Ipairs: ~6.7 Microseconds
Pairs:: 6.44 Microseconds

Test 2: Concate of strings

In this test, we will create an array of strings and concatenate them into one string at each iteration using all three iteration methods. To generate strings, we will use string.char

Concate script

functions:

local function concatIterationWithFor(array)
	local result = ""
	for _, value in array do
		result ..= value
	end
	return result
end

local function concatIterationWithIpairs(array)
	local result = ""
	for _, value in ipairs(array) do
		result ..= value
	end
	return result
end

local function concatIterationWithPairs(array)
	local result = ""
	for _, value in pairs(array) do
		result ..= value
	end
	return result
end

Now let’s create an array of strings and perform concatenation:

local testArray = {}
local numStrings = 1000
for i = 1, numStrings do
   testArray[i] = string.char(math.random(65, 90))
end
local numIterations = 10000

local startFor = os.clock()
local resultFor
for i = 1, numIterations do
    resultFor = concatIterationWithFor(testArray)
end
local endFor = os.clock()
local timeFor = endFor - startFor
local avgTimeFor = timeFor / numIterations

local startIpairs = os.clock()
local resultIpairs
for i = 1, numIterations do
    resultIpairs = concatIterationWithIpairs(testArray)
end
local endIpairs = os.clock()
local timeIpairs = endIpairs - startIpairs
local avgTimeIpairs = timeIpairs / numIterations

local startPairs = os.clock()
local resultPairs
for i = 1, numIterations do
    resultPairs = concatIterationWithPairs(testArray)
end
local endPairs = os.clock()
local timePairs = endPairs - startPairs
local avgTimePairs = timePairs / numIterations

The results surprised me very much
After calculating the average time for each method, I got the following results:

no iterators: 303.65 microseconds.
ipairs: 303.45 microseconds.
pairs: 305.98 microseconds.

ipairs turned out to be about 0.07% faster than for _, value in array. pairs turned out to be about 0.75% slower than for _, value in array. These differences, except for pairs, are minor.

Test 3: Creating new tables

I ran only 10 tests with 1000 iterations.
In this test, we will create an array with data and create a new table at each iteration using all three iteration methods.

New tables script

First, let’s define the functions for each iteration method:

local function createTableIterationWithFor(array)
   local result = {}
   for _, value in array do
       result[#result + 1] = {data = value}
   end
   return result
end

local function createTableIterationWithIpairs(array)
   local result = {}
   for _, value in ipairs(array) do
       result[#result + 1] = {data = value}
   end
   return result
end

local function createTableIterationWithPairs(array)
  local result = {}
   for _, value in pairs(array) do
        result[#result + 1] = {data = value}
   end
   return result
end

Now let’s create an array and create new tables:

local testArray = {}
for i = 1, 1000 do
	testArray[i] = i
end

local numIterations = 1000

local startFor = os.clock()
local resultFor
for i = 1, numIterations do
	resultFor = createTableIterationWithFor(testArray)
end
local endFor = os.clock()
local timeFor = endFor - startFor
local avgTimeFor = timeFor / numIterations

local startIpairs = os.clock()
local resultIpairs
for i = 1, numIterations do
	resultIpairs = createTableIterationWithIpairs(testArray)
end
local endIpairs = os.clock()
local timeIpairs = endIpairs - startIpairs
local avgTimeIpairs = timeIpairs / numIterations

local startPairs = os.clock()
local resultPairs
for i = 1, numIterations do
	resultPairs = createTableIterationWithPairs(testArray)
end
local endPairs = os.clock()
local timePairs = endPairs - startPairs
local avgTimePairs = timePairs / numIterations

print("no iterator:", avgTimeFor * 1000 * 1000)
print("ipairs:", avgTimeIpairs * 1000 * 1000)
print("pairs:", avgTimePairs * 1000 * 1000)

although pairs lagged behind on average, ipairs, in turn, was even a little faster in terms of speed. Although sometimes he had strange drawdowns in speed. Maybe it’s because of my microwave.
test №6 61.58 64.55 69.30

Thus, on average, ipairs turned out to be about 0.61% slower than for _, value in array, and pairs turned out to be about 1.56% slower than for _, value in array.

As you may have noticed, the results of this test are a little more contradictory than the results of previous tests. Although pairs proved to be the slowest on average, ipairs was faster than for _, value in array in several tests (I didn’t save them, though, so take it literally, lol)

However, after averaging the data, we see that for _, value in array is the most stable way to iterate over arrays, and shows the best result on average.

Conclusion

Our research has shown that although the performance difference between for _, value in array, ipairs and pairs may not always be obvious, for _, value in array is the fastest and most reliable option for iterating over arrays in Luau. I hope this tutorial was useful for you and will help you write more efficient and faster code! Thank you for your attention and have a good coding experience!”

By the way, yes. I didn’t give a damn about using the principle that you don’t have to repeat yourself in the code. Sorry

12 Likes

From my benchmarks, for i, v in next, arr do is fastest for small arrays, for larger arrays you can use pairs without much difference.

Some improvements for your benchmarks

  • Don’t use mean for this type of benchmark as the results are not in normal distribution. Use median instead.
  • Test with cheaper operations like addition so you are actually testing iteration performance and not something else
2 Likes

what was a point of this ? if it works it works lol

You’re right. These are my mistakes.

pairs and ipairs are not unreliable.

Additionally, ipairs shouldn’t be considered in any benchmarking tests (understanding this is a micro-optimization; it’s a separate discussion as to whether any performance improvements can actually be improved by the human eye). It necessarily does different things than pairs or a pairs equivalent. They aren’t 1:1, so it doesn’t make sense to compare them against each other.

1 Like

They do the same things for arrays

1 Like

Not if any of the values are set to nil. They are not functionally equivalent, and that is not a debate.

They are functionally equivalent for arrays without gaps

image

By the way, thanks. Your advice about using medians better is really cool advice.