DEV Community

Aliaksandr Valshtein
Aliaksandr Valshtein

Posted on

How Comparison Optimization Makes Python Sorting Faster

In this text the terms Python and CPython, which is the reference implementation of the language, are used interchangeably. This article specifically addresses CPython and does not concern any other implementation of Python.

Python is a beautiful language that allows a programmer to express their ideas in simple terms leaving the complexity of the actual implementation behind the scenes.

One of the things it abstracts away is sorting.

You can easily find the answer to the question "how sorting is implemented in Python?" which almost always answers another question: "What sorting algorithm does Python use?".

However, this often leaves some interesting implementations details behind.

There is one implementation detail that I think isn't discussed enough, even though it was introduced over seven years ago in python 3.7:

sorted() and list.sort() have been optimized for common cases to be up to 40-75% faster. (Contributed by Elliot Gorokhovsky in bpo-28685.)

But before we start...

Brief Re-introduction to Sorting in Python

When you need to sort a list in python, you have two options:

If you need to sort any other built-in iterable, you can only use sorted regardless of the type of iterable or generator passed as a parameter.

sorted always returns a list because it uses list.sort internally.

Here is a rough equivalent of CPython's sorted C implementation rewritten in pure python:

def sorted(iterable: Iterable[Any], key=None, reverse=False):
    new_list = list(iterable)
    new_list.sort(key=key, reverse=reverse)
    return new_list
Enter fullscreen mode Exit fullscreen mode

Yes, it's that simple.

How Python Makes Sorting Faster

As Python's internal documentation for sorting puts it:

It is sometimes possible to substitute faster type-specific comparisons for the slower, generic PyObject_RichCompareBool

And in short this optimization can be described as follows:

When a list is homogeneous, Python uses type-specific comparison function

What Is a Homogeneous List?

A homogeneous list is a list that contains elements only of one type.

For example:

homogeneous = [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

On the other hand, this is not a homogeneous list:

heterogeneous = [1, "2", (3, ), {'4': 4}]
Enter fullscreen mode Exit fullscreen mode

Interestingly, official Python tutorial states:

Lists are mutable, and their elements are usually homogeneous and are accessed by iterating over the list

A side note about tuples

That same tutorial states:

Tuples are immutable, and usually contain a heterogeneous sequence of elements

So if you ever wonder when to use a tuple or a list here's a rule of thumb:
if elements are of the same type, use a list, otherwise use a tuple

Wait, and what about arrays ?

Python implements a homogeneous array container object for numeric values.

However, as of python 3.12, arrays do not implement their own sort method.

The only way to sort them is by using sorted, which internally creates a list out of the array, erasing any type-related information in the process.

Why Using Type-Specific Comparison Function Helps?

Comparisons in python are costly, because Python performs various checks before doing any actual comparison.

Here is a simplified explanation of what happens under the hood when you compare two values in python:

  • Python checks that the values passed to comparison function are not NULL
  • If values are of different types, but right operand is a subtype of the left, Python uses right operand's comparison function, but reversed (e.g., it will use < for >)
  • If the values are of the same type, or different types but neither is a subtype of the other:
    • Python will first try left operand's comparison function
    • If that fails, it will try right operand's comparison function, but reversed.
    • If that fails too, and the comparison is for equality or inequality, it will return identity comparison (True for values that refer to the same object in memory)
    • Otherwise, it raises TypeError

How Python Compares Two Values

On top of this, each type's own comparison functions implement additional checks.

For example, when comparing strings, Python will check if the string characters take more than one byte of memory, and float comparison will compare a pair of float's and a float and an int differently.

A more detailed explanation and diagram can be found here: Adding Data-Aware Sort Optimizations to CPython

Before this optimization was introduced, Python had to execute all this various type-specific and non-type-specific checks every time two values were compared during sorting.

Checking List Element's Types in Advance

There's no magical way to know if all the elements of a list are of the same type other than to iterate over the list and check each element.

Python does almost exactly that — checking the types of sorting keys generated by key function passed to list.sort or sorted as a parameter

Constructing a List of Keys

If a key function is provided, Python uses it to construct a list of keys, otherwise it uses the list's own values as sorting keys.

In an oversimplified manner, keys construction can be expressed as the following python code.

if key is None:
    keys = list_items
else:
    keys = [key(list_item) for list_item in list_item]
Enter fullscreen mode Exit fullscreen mode

Note, that keys used internally in CPython are a C array of CPython object references, and not a Python list

Once the keys are constructed, Python checks their types.

Checking Key's Type

When checking the types of keys, Python's sorting algorithm tries to determine if all elements in the keys array are either str, int, float or tuple, or simply of the same type, with some constraints for base types.

It's worth noting that checking the types of the keys adds some extra work up front. Python does this because it usually pays off by making the actual sorting faster, especially for longer lists.

int constraints

int should not be a bignum

Practically this means that for this optimization to work, integer should be less than 2^30 - 1 (this may vary depending on the platform)

As a side note, here is a great article which explains how Python handles big integers: # How python implements super long integers?

str constraints

All characters of a string should take less than 1 byte of memory, meaning that they should be represented by integer values in the range of 0-255

In practice, this means that strings should consist only of Latin characters, spaces, and some special characters found in the ASCII table.

float constraints

There are no constraints for floats in order for this optimization to work.

tuple constraints

  • Only the first element's type is checked
  • This element itself should not be a tuple itself
  • If all tuples share the same type for their first element, the comparison optimization is applied to them
  • All other elements are compared as usual

How Can I Apply This Knowledge?

First of all, isn’t it fascinating to know?

Secondly, mentioning this knowledge could be a nice touch in a Python Developer interview.

As for actual code development, understanding this optimization can help you improve sorting performance.

Optimize by Selecting the Type of Values Wisely

According to the benchmark in the PR that introduced this optimization, sorting a list that consists only of floats rather than a list of floats with even a single integer at the end is almost twice as fast.

So when it's time to optimize, transforming list like this

floats_and_int = [1.0, -1.0, -0.5, 3]
Enter fullscreen mode Exit fullscreen mode

Into list that looks like this

just_floats = [1.0, -1.0, -0.5, 3.0] # note that 3.0 is a float now
Enter fullscreen mode Exit fullscreen mode

might improve performance.

Optimize by Using Keys for Lists of Objects

While Python's sorting optimization works well with built-in types, it's important to understand how it interacts with custom classes.

When sorting objects of custom classes, Python relies on the comparison methods you define, such as __lt__ (less than) or __gt__ (greater than).

However, the type-specific optimization doesn't apply to custom classes.
Python will always use the general comparison method for these objects.

Here's an example:

class MyClass:
    def __init__(self, value): 
        self.value = value 

    def __lt__(self, other): 
        return self.value < other.value 

my_list = [MyClass(3), MyClass(1), MyClass(2)] 
sorted_list = sorted(my_list)
Enter fullscreen mode Exit fullscreen mode

In this case, Python will use the __lt__ method for comparisons, but it won't benefit from the type-specific optimization. The sorting will still work correctly, but it may not be as fast as sorting built-in types.

If performance is critical when sorting custom objects, consider using a key function that returns a built-in type:

sorted_list = sorted(my_list, key=lambda x: x.value)
Enter fullscreen mode Exit fullscreen mode

Afterword

Premature optimization, especially in Python, is evil.

You should not design your entire application around specific optimizations in CPython, but it’s good to be aware of these optimizations: knowing your tools well is a way of becoming a more skilled developer.

Being mindful of optimizations like these allows you to take advantage of them when the situation calls for it, especially when performance becomes critical:

Consider a scenario where were your sorting is based on timestamps: using a homogeneous list of integers (Unix timestamps) instead of datetime objects could leverage this optimization effectively.

However, it's crucial to remember that code readability and maintainability should take precedence over such optimizations.

While it's important to know about these low-level details, it is as much important to appreciate Python's high-level abstractions that make it such a productive language.

Python is an amazing language, and exploring its depths can help you understand it better and become a better Python programmer.

Top comments (0)