77

Here are two measurements:

timeit.timeit('"toto"=="1234"', number=100000000)
1.8320042459999968
timeit.timeit('"toto"=="toto"', number=100000000)
1.4517491540000265

As you can see, comparing two strings that match is faster than comparing two strings with the same size that do not match. This is quite disturbing: During a string comparison, I believed that Python was testing strings character by character, so "toto"=="toto" should be longer to test than "toto"=="1234" as it requires four tests against one for the non-matching comparison. Maybe the comparison is hash-based, but in this case, timings should be the same for both comparisons.

Why?

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Eric
  • 4,821
  • 6
  • 33
  • 60
  • 18
    string interning maybe? – Sayse Mar 28 '22 at 08:28
  • 30
    Check the value of `"toto" is "toto"`. It is very likely that two identical string literals in the same statement are being compiled to the same string object. I imagine you'd get a different result if your strings were produced by different means. – khelwood Mar 28 '22 at 08:35
  • Interesting. Can you please update the question to show the byte-code for the two comparisons? (I’d *presume* they are the same …?) – S3DEV Mar 28 '22 at 08:37
  • have you done it multiple times? do you always have the same rang of values for them? – Ren Mar 28 '22 at 09:03
  • The comparison between integers seems to behave in the same way. The same does not seem to be true for floats – Riccardo Bucco Mar 28 '22 at 09:13
  • 1
    @RiccardoBucco "small integers" (from -5 to 255 IIRC) are actually memoized up front, they're always going to be obtained from the cache. And so identity-checking them also makes a lot of sense. – Masklinn Mar 28 '22 at 09:27
  • @Masklinn But maybe it's because of identity? `x=2.5;y=2.5;id(x)==id(y)` holds `False`, while `x=3;y=3;id(x)==id(y)` holds `True` – Riccardo Bucco Mar 28 '22 at 09:29
  • 1
    @RiccardoBucco well yes, but the reason you have the same identity is that small integers are cached (in cpython, as an implementation detail). There's no such cache for float, so two instances of the same literal are different objects. And because the likelihood of encountering identical floats (the same object, not the same value) is low (as they're not cached) cpython does not optimise this comparison. – Masklinn Mar 28 '22 at 10:06
  • See [`long_richcompare`](https://github.com/python/cpython/blob/main/Objects/longobject.c#L3063-L3073) which starts with a check for `self == other` versus [`float_richcompare`](https://github.com/python/cpython/blob/main/Objects/floatobject.c#L391-L403) which does not – Masklinn Mar 28 '22 at 10:06
  • 1
    _"During a string comparison, I believed that python was testing strings char by char"_ - I sincerely doubt any decent programming language uses a naive for loop for string comparison. Python certainly doesn't, [it uses memcmp](https://github.com/python/cpython/blob/main/Objects/stringlib/eq.h#L23), which [may use SIMD instructions to compare many bytes at a time](https://stackoverflow.com/a/21106815/4782863), among other optimizations. – marcelm Mar 29 '22 at 11:47

2 Answers2

75

Combining my comment and the comment by @khelwood:

TL;DR:
When analysing the bytecode for the two comparisons, it reveals the 'time' and 'time' strings are assigned to the same object. Therefore, an up-front identity check (at C-level) is the reason for the increased comparison speed.

The reason for the same object assignment is that, as an implementation detail, CPython interns strings which contain only 'name characters' (i.e. alpha and underscore characters). This enables the object's identity check.


Bytecode:

import dis

In [24]: dis.dis("'time'=='time'")
  1           0 LOAD_CONST               0 ('time')  # <-- same object (0)
              2 LOAD_CONST               0 ('time')  # <-- same object (0)
              4 COMPARE_OP               2 (==)
              6 RETURN_VALUE

In [25]: dis.dis("'time'=='1234'")
  1           0 LOAD_CONST               0 ('time')  # <-- different object (0)
              2 LOAD_CONST               1 ('1234')  # <-- different object (1)
              4 COMPARE_OP               2 (==)
              6 RETURN_VALUE

Assignment Timing:

The 'speed-up' can also be seen in using assignment for the time tests. The assignment (and compare) of two variables to the same string, is faster than the assignment (and compare) of two variables to different strings. Further supporting the hypothesis the underlying logic is performing an object comparison. This is confirmed in the next section.

In [26]: timeit.timeit("x='time'; y='time'; x==y", number=1000000)
Out[26]: 0.0745926329982467

In [27]: timeit.timeit("x='time'; y='1234'; x==y", number=1000000)
Out[27]: 0.10328884399496019

Python source code:

As helpfully provided by @mkrieger1 and @Masklinn in their comments, the source code for unicodeobject.c performs a pointer comparison first and if True, returns immediately.

int
_PyUnicode_Equal(PyObject *str1, PyObject *str2)
{
    assert(PyUnicode_CheckExact(str1));
    assert(PyUnicode_CheckExact(str2));
    if (str1 == str2) {                  // <-- Here
        return 1;
    }
    if (PyUnicode_READY(str1) || PyUnicode_READY(str2)) {
        return -1;
    }
    return unicode_compare_eq(str1, str2);
}

Appendix:

  • Reference answer nicely illustrating how to read the disassembled bytecode output. Courtesy of @Delgan
  • Reference answer which nicely describes CPython's string interning. Coutresy of @ShadowRanger
S3DEV
  • 8,768
  • 3
  • 31
  • 42
  • Why is the comparison of two objects faster if they represent the same object? How is the comparison operator implemented? – Riccardo Bucco Mar 28 '22 at 09:15
  • 8
    For strings, it's implemented here: https://github.com/python/cpython/blob/main/Objects/unicodeobject.c#L11134 As expected, it checks identity first and returns early. – mkrieger1 Mar 28 '22 at 09:23
  • 14
    @RiccardoBucco because equality checks will often start with an *identity* check, as that's ridiculously cheap to perform but extremely efficient if it lets you bypass a "structural" equality check. You can see this in [`_PyUnicode_Equal`](https://github.com/python/cpython/blob/850687df47b03e98c1433e6e70e71a8921eb4454/Objects/unicodeobject.c#L11134-L11146). Lines 11139 to 11141 is a C-level equality check, meaning it compares the pointer, which in CPython is an identity comparison (as two objects can not overlap, and thus can't have the same pointer). – Masklinn Mar 28 '22 at 09:24
  • @mkrieger1 - Exactly what I was looking for, thank you. Will include in the answer. – S3DEV Mar 28 '22 at 09:24
  • Does python globally cache every string? Otherwise I think what is stated in the question is not correct, it is not faster to compare strings that match in the general sense, however it is faster to compare an object with itself. It is probably generally faster to compare strings that do not match as it can bail out early. – Yanick Salzmann Mar 29 '22 at 07:14
  • 3
    @YanickSalzmann CPython currently caches (interns) strings that contain only word characters. See https://stackoverflow.com/questions/42684966/are-strings-cached . – David Hammen Mar 29 '22 at 10:48
53

It's not always faster to compare strings that match. Instead, it's always faster to compare strings that share the same id. A proof that identity is indeed the reason of this behavior (as @S3DEV has brilliantly explained) is this one:

>>> x = 'toto'
>>> y = 'toto'
>>> z = 'totoo'[:-1]
>>> w = 'abcd'
>>> x == y
True
>>> x == z
True
>>> x == w
False
>>> id(x) == id(y)
True
>>> id(x) == id(z)
False
>>> id(x) == id(w)
False
>>> timeit.timeit('x==y', number=100000000, globals={'x': x, 'y': y})
3.893762200000083
>>> timeit.timeit('x==z', number=100000000, globals={'x': x, 'z': z})
4.205321462000029
>>> timeit.timeit('x==w', number=100000000, globals={'x': x, 'w': w})
4.15288594499998

It's always faster to compare objects having the same id (as you can notice from the example, the comparison between x and z is slower compared to the comparison between x and y, and that's because x and z do not share the same id).

Riccardo Bucco
  • 13,980
  • 4
  • 22
  • 50
  • 5
    FYI, the straightforward test for "are they the same object?" is `x is y`; `id(x) == id(y)` does get the same result, but it does some thumb-twiddling first to make `int` objects to compare, where `x is y` just compares the memory address directly without wrapping it. – ShadowRanger Mar 30 '22 at 00:59