GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — gh-132657: Avoid locks and refcounts in frozenset operations by eendebakpt · Pull Request #136107 · python/cpython

Conversation

@eendebakpt

@eendebakpt eendebakpt commented Jun 29, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

See the corresponding issue for the rationale.

The PR improves performance of the frozenset operations under the FT build. A benchmark:

Script
import time


s = {1, 2, 3}
fs = frozenset(s)

dt = 0
dtd = 0
dtf = 0
dtdf = 0

niter = 30_000
nloop = 120


def g():
    t0 = time.perf_counter()
    z = 2
    for _ in range(niter):
        z in s
    return time.perf_counter() - t0


def gfrozen():
    t0 = time.perf_counter()
    z = 2
    for _ in range(niter):
        z in fs
    return time.perf_counter() - t0


def g_dunder():
    t0 = time.perf_counter()
    z = 2
    contains_dunder = s.__contains__
    for _ in range(niter):
        contains_dunder(z)
    return time.perf_counter() - t0


def gfrozen_dunder():
    t0 = time.perf_counter()
    z = 2
    contains_dunder = fs.__contains__
    for _ in range(niter):
        contains_dunder(z)
    return time.perf_counter() - t0


# interleaved benchmark of the methods
for ii in range(nloop):
    # print('-')
    dt += g()
    dtf += gfrozen()
    dtd += g_dunder()
    dtdf += gfrozen_dunder()


print(f"set dt {dt * 1e3:.2f} [ms]")
print(f"frozenset dt {dtf * 1e3:.2f} [ms]")

print(f"set dunder dt {dtd * 1e3:.2f} [ms]")
print(f"frozenset dunder dt {dtdf * 1e3:.2f} [ms]")

Results on main (interleaved benchmark)

set dt 170.07 [ms]
frozenset dt 170.60 [ms]
set dunder dt 210.17 [ms]
frozenset dunder dt 182.84 [ms]

Results on PR (interleaved benchmark)

set dt 177.96 [ms]
frozenset dt 145.31 [ms]
set dunder dt 220.28 [ms]
frozenset dunder dt 185.37 [ms]

The benchmark "frozenset" is improved since this triggers the path taken by the adaptive interpreter for z in s.
The two dunder benchmarks use s.__contains__(z).

The FT scaling performance is not improved a lot. This is probably due to refcount contention on the global objects (the set and frozenset). Because the operation itself is faster, the scaling performance itself looks worse.

Script adapted from the ftscalingbench.py
import copy
import os
import queue
import sys
import threading
import time

WORK_SCALE = 100

ALL_BENCHMARKS = {}

threads = []
in_queues = []
out_queues = []


def register_benchmark(func):
    ALL_BENCHMARKS[func.__name__] = func
    return func


small_tuple = (1, 2, 3, 4)
small_list = [1, 2, 3, 4]
small_set = {1, 2, 3, 4}
small_frozenset = frozenset({1, 2, 3, 4})


@register_benchmark
def tuple_contains():
    z = 0
    for i in range(500 * WORK_SCALE):
        z in small_tuple


@register_benchmark
def list_contains():
    z = 0
    for i in range(500 * WORK_SCALE):
        z in small_list


@register_benchmark
def frozenset_contains():
    z = 1
    for i in range(500 * WORK_SCALE):
        z in small_frozenset

@register_benchmark
def frozenset_contains_dunder():
    z = 1
    w = small_frozenset.__contains__
    for i in range(500 * WORK_SCALE):
        w(z)


@register_benchmark
def set_contains():
    z = 1
    w=small_set.__contains__
    for i in range(500 * WORK_SCALE):
        w(z)


@register_benchmark
def set_contains_alt():
    z = 0
    for i in range(500 * WORK_SCALE):
        z in tuple(small_set)


@register_benchmark
def shallow_copy():
    x = [1, 2, 3]
    shallow_copy = copy.copy
    for i in range(400 * WORK_SCALE):
        shallow_copy(x)


@register_benchmark
def deepcopy():
    x = {"list": [1, 2], "tuple": (1, None)}
    deepcopy = copy.deepcopy
    for i in range(80 * WORK_SCALE):
        deepcopy(x)


module = sys.modules[__name__]

thread_local = threading.local()


def bench_one_thread(func):
    t0 = time.perf_counter_ns()
    func()
    t1 = time.perf_counter_ns()
    return t1 - t0


def bench_parallel(func):
    t0 = time.perf_counter_ns()
    for inq in in_queues:
        inq.put(func)
    for outq in out_queues:
        outq.get()
    t1 = time.perf_counter_ns()
    return t1 - t0


def benchmark(func):
    delta_one_thread = bench_one_thread(func)
    delta_many_threads = bench_parallel(func)

    speedup = delta_one_thread * len(threads) / delta_many_threads
    if speedup >= 1:
        factor = speedup
        direction = "faster"
    else:
        factor = 1 / speedup
        direction = "slower"

    use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
    color = reset_color = ""
    if use_color:
        if speedup <= 1.1:
            color = "\x1b[31m"  # red
        elif speedup < len(threads) / 2:
            color = "\x1b[33m"  # yellow
        reset_color = "\x1b[0m"

    print(f"{color}{func.__name__:<25} {round(factor, 1):>4}x {direction}{reset_color}")


def determine_num_threads_and_affinity():
    if sys.platform != "linux":
        return [None] * os.cpu_count()

    # Try to use `lscpu -p` on Linux
    import subprocess

    try:
        output = subprocess.check_output(["lscpu", "-p=cpu,node,core,MAXMHZ"], text=True, env={"LC_NUMERIC": "C"})
    except (FileNotFoundError, subprocess.CalledProcessError):
        return [None] * os.cpu_count()

    table = []
    for line in output.splitlines():
        if line.startswith("#"):
            continue
        cpu, node, core, maxhz = line.split(",")
        if maxhz == "":
            maxhz = "0"
        table.append((int(cpu), int(node), int(core), float(maxhz)))

    cpus = []
    cores = set()
    max_mhz_all = max(row[3] for row in table)
    for cpu, node, core, maxmhz in table:
        # Choose only CPUs on the same node, unique cores, and try to avoid
        # "efficiency" cores.
        if node == 0 and core not in cores and maxmhz == max_mhz_all:
            cpus.append(cpu)
            cores.add(core)
    return cpus


def thread_run(cpu, in_queue, out_queue):
    if cpu is not None and hasattr(os, "sched_setaffinity"):
        # Set the affinity for the current thread
        os.sched_setaffinity(0, (cpu,))

    while True:
        func = in_queue.get()
        if func is None:
            break
        func()
        out_queue.put(None)


def initialize_threads(opts):
    if opts.threads == -1:
        cpus = determine_num_threads_and_affinity()
    else:
        cpus = [None] * opts.threads  # don't set affinity

    print(f"Running benchmarks with {len(cpus)} threads")
    for cpu in cpus:
        inq = queue.Queue()
        outq = queue.Queue()
        in_queues.append(inq)
        out_queues.append(outq)
        t = threading.Thread(target=thread_run, args=(cpu, inq, outq), daemon=True)
        threads.append(t)
        t.start()


def main(opts):
    global WORK_SCALE
    if not hasattr(sys, "_is_gil_enabled") or sys._is_gil_enabled():
        sys.stderr.write("expected to be run with the  GIL disabled\n")

    benchmark_names = opts.benchmarks
    if benchmark_names:
        for name in benchmark_names:
            if name not in ALL_BENCHMARKS:
                sys.stderr.write(f"Unknown benchmark: {name}\n")
                sys.exit(1)
    else:
        benchmark_names = ALL_BENCHMARKS.keys()

    WORK_SCALE = opts.scale

    if not opts.baseline_only:
        initialize_threads(opts)

    do_bench = not opts.baseline_only and not opts.parallel_only
    for name in benchmark_names:
        func = ALL_BENCHMARKS[name]
        if do_bench:
            benchmark(func)
            continue

        if opts.parallel_only:
            delta_ns = bench_parallel(func)
        else:
            delta_ns = bench_one_thread(func)

        time_ms = delta_ns / 1_000_000
        print(f"{func.__name__:<18} {time_ms:.1f} ms")


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("-t", "--threads", type=int, default=-1, help="number of threads to use")
    parser.add_argument("--scale", type=int, default=100, help="work scale factor for the benchmark (default=100)")
    parser.add_argument(
        "--baseline-only", default=False, action="store_true", help="only run the baseline benchmarks (single thread)"
    )
    parser.add_argument(
        "--parallel-only", default=False, action="store_true", help="only run the parallel benchmark (many threads)"
    )
    parser.add_argument("benchmarks", nargs="*", help="benchmarks to run")
    options = parser.parse_args()
    main(options)

Results on main

Running benchmarks with 4 threads
tuple_contains             3.8x faster
list_contains              2.1x slower
frozenset_contains         2.8x slower
frozenset_contains_dunder  4.0x faster
set_contains               1.1x slower
set_contains_alt           1.3x slower
shallow_copy               1.0x slower
deepcopy                   1.8x faster

Results on PR

Running benchmarks with 4 threads
tuple_contains             3.8x faster
list_contains              2.1x slower
frozenset_contains         4.4x slower
frozenset_contains_dunder  3.8x faster
set_contains               1.3x slower
set_contains_alt           1.3x slower
shallow_copy               1.1x faster
deepcopy                   1.6x faster
  • Issue: copy.copy and copy.deepcopy scale poorly with free-threading #132657

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@eendebakpt
Avoid locks are and refcounts in frozenset operatins
e630b64
@eendebakpt
eendebakpt requested a review from rhettinger as a code owner June 29, 2025 20:27
@bedevere-app bedevere-app Bot added the awaiting review label Jun 29, 2025
@bedevere-app bedevere-app Bot mentioned this pull request Jun 29, 2025
copy.copy and copy.deepcopy scale poorly with free-threading #132657
Closed
@eendebakpt
avoid table check
40d2d29
eendebakpt
eendebakpt commented Jun 29, 2025
View reviewed changes
Comment thread Objects/setobject.c
return NULL;
if (table != so->table || entry->key != startkey)
return set_lookkey(so, key, hash);
if (frozenset) {

@eendebakpt eendebakpt Jun 29, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the special case for unicode a couple of lines above was introduced to avoid the check on mutated tables. See commit eendebakpt@93035c4 that moved the code inline.

With the check for an exact frozenset we avoid the same checks.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@rhettinger
rhettinger removed their request for review June 30, 2025 06:07
@ZeroIntensity ZeroIntensity added the performance Performance or resource usage label Jul 1, 2025
@blurb-it
📜🤖 Added by blurb_it.
10aed73
@nascheme

nascheme commented Jul 11, 2025

Copy link
Copy Markdown
Member

There is also GH-132290 which I think would do similar things. It's missing the refcount optimizations that this PR has. And it provides lock-free contains for both set and frozenset.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@eendebakpt

eendebakpt commented Jul 13, 2025

Copy link
Copy Markdown
Contributor Author

There is also GH-132290 which I think would do similar things. It's missing the refcount optimizations that this PR has. And it provides lock-free contains for both set and frozenset.

I think the other PR now also has the refcount optimizations. So if that PR works, we can close this one.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@kumaraditya303

kumaraditya303 commented Aug 14, 2025

Copy link
Copy Markdown
Contributor

I think we should get this in first as the other PR is much larger and will take time.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@eendebakpt
Merge branch 'main' into frozenset_specialization
574e0f4
kumaraditya303
kumaraditya303 approved these changes Aug 15, 2025
View reviewed changes
@bedevere-app bedevere-app Bot added awaiting merge and removed awaiting review labels Aug 15, 2025
@eendebakpt
Merge branch 'main' into frozenset_specialization
7b4240e
@kumaraditya303
kumaraditya303 merged commit f58a7c7 into python:main Aug 30, 2025
@bedevere-app bedevere-app Bot removed the awaiting merge label Aug 30, 2025
lkollar pushed a commit to lkollar/cpython that referenced this pull request Sep 9, 2025
@eendebakpt @lkollar
pythongh-132657: avoid locks and refcounting in frozenset lookups (p…
929810e
…ython#136107)
@nascheme nascheme added the needs backport to 3.14 bugs and security fixes label Nov 19, 2025
@miss-islington-app

miss-islington-app Bot commented Nov 19, 2025

Copy link
Copy Markdown

Thanks @eendebakpt for the PR, and @kumaraditya303 for merging it 🌮🎉.. I'm working now to backport this PR to: 3.14.
🐍🍒⛏🤖

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

miss-islington pushed a commit to miss-islington/cpython that referenced this pull request Nov 19, 2025
@eendebakpt @miss-islington
pythongh-132657: avoid locks and refcounting in frozenset lookups (p…
f3046c7
…ythonGH-136107)

(cherry picked from commit f58a7c7)

Co-authored-by: Pieter Eendebak <[email protected]>
@nascheme nascheme added the topic-free-threading label Nov 19, 2025
@bedevere-app

bedevere-app Bot commented Nov 19, 2025

Copy link
Copy Markdown

GH-141772 is a backport of this pull request to the 3.14 branch.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@bedevere-app bedevere-app Bot removed the needs backport to 3.14 bugs and security fixes label Nov 19, 2025
nascheme pushed a commit that referenced this pull request Nov 20, 2025
@miss-islington @eendebakpt
[3.14] gh-132657: avoid locks and refcounting in frozenset lookups (G…
c05e71f
…H-136107) (gh-141772)

gh-132657: avoid locks and refcounting in `frozenset` lookups (GH-136107)
(cherry picked from commit f58a7c7)

Co-authored-by: Pieter Eendebak <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@kumaraditya303 kumaraditya303 kumaraditya303 approved these changes

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

performance Performance or resource usage topic-free-threading

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

4 participants

@eendebakpt @nascheme @kumaraditya303 @ZeroIntensity
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.