Skip to content

Optimize iter_index itertools recipe #102088

@pochmann

Description

@pochmann

Documentation

The "Slow path for general iterables" somewhat reinvents operator.indexOf. it seems faster to use it, and could show off an effective combination of the other itertools.

Benchmark with the current implementation and two new alternatives, finding the indices of 0 in a million random digits:

 108.1 ms ± 0.1 ms  iter_index_current
  39.7 ms ± 0.3 ms  iter_index_new1
  31.0 ms ± 0.1 ms  iter_index_new2

Python version:
3.10.8 (main, Oct 11 2022, 11:35:05) [GCC 11.3.0]

Code of all three (just the slow path portion):

def iter_index_current(iterable, value, start=0):
    it = islice(iterable, start, None)
    for i, element in enumerate(it, start):
        if element is value or element == value:
            yield i

def iter_index_new1(iterable, value, start=0):
    it = islice(iterable, start, None)
    i = start - 1
    try:
        while True:
            yield (i := i+1 + operator.indexOf(it, value))
    except ValueError:
        pass

def iter_index_new2(iterable, value, start=0):
    it = iter(iterable)
    consume(it, start)
    i = start - 1
    try:
        for d in starmap(operator.indexOf, repeat((it, value))):
            yield (i := i + (1 + d))
    except ValueError:
        pass

The new1 version is written to be similar to the "Fast path for sequences". The new2 version has optimizations and uses three more itertools /recipes. Besides using starmap(...), the optimizations are:

  • Not piping all values through an islice iterator, instead only using consume to advance the direct iterator it, then using it.
  • Add 1 to d, which is more often one of the existing small ints (up to 256) than adding 1 to i.
Rest of benchmark script
funcs = iter_index_current, iter_index_new1, iter_index_new2

from itertools import islice, repeat, starmap
from timeit import timeit
import collections
import random
from statistics import mean, stdev
import sys
import operator

# Existing recipe
def consume(iterator, n=None):
    if n is None:
        collections.deque(iterator, maxlen=0)
    else:
        next(islice(iterator, n, n), None)

# Test arguments
iterable = random.choices(range(10), k=10**6)
value = 0
start = 100
def args():
    return iter(iterable), value, start
 
# Correctness   
expect = list(funcs[0](*args()))
for f in funcs[1:]:
    print(list(f(*args())) == expect, f.__name__)

# For timing
times = {f: [] for f in funcs}
def stats(f):
    ts = [t*1e3 for t in sorted(times[f])[:5]]
    return f'{mean(ts):6.1f} ms ± {stdev(ts):3.1f} ms '

# Timing
number = 1
for _ in range(25):
    for f in funcs:
        t = timeit(lambda: consume(f(*args())), number=number) / number
        times[f].append(t)

# Timing results
for f in sorted(funcs, key=stats, reverse=True):
    print(stats(f), f.__name__)

print('\nPython version:')
print(sys.version)

Linked PRs

Metadata

Metadata

Assignees

Labels

docsDocumentation in the Doc dirperformancePerformance or resource usage

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions