Repository: pydata/numexpr
Pull request #508: Add a benchmark/example for numexpr usage under free-threading conditions
Author: login: andfoy
Created: 2025-03-27T17:05:32Z | Merged: 2025-04-09T03:22:07Z

Fixes #503

Linked issue #503: Support free-threaded Python 3.13
This tracking issue aims to collect all the issues and PRs related for the current free-threaded CPython 3.13 (a.k.a. "no-GIL") builds. 
For context, this new experimental CPython version enables multithreaded programs to get around the GIL limitation, thus reducing the execution time, whilst introducing a whole set of concurrency and parallelism issues.

So far, as part of a general effort, several key packages in the PyData ecosystem have been tested for concurrency issues in order to produce adequate and working wheels for free-threaded Python.

We invite the community and maintainers with extensive knowledge of the project to highlight the existence of any potential thread-safety issues that currently exist in `numexpr`.

Here are some resources that might be useful in the context of free-threaded Python

* [PEP 703](https://peps.python.org/pep-0703/)
* Corresponding tracker in NumPy: https://github.com/numpy/numpy/issues/26157
* Corresponding tracker in SciPy: https://github.com/scipy/scipy/issues/20669
* Corresponding tracker in Pillow: https://github.com/python-pillow/Pillow/issues/8199
* Corresponding tracker in Pandas: https://github.com/pandas-dev/pandas/issues/59057
* Free-threaded Python community Wiki: https://py-free-threading.github.io/
Linked issue comments: user: login: rgommers
created_at: 2025-02-18T09:04:50Z
body: Hi @FrancescAlted & other maintainers, I'd like to add a bit of context to this issue. Our team at Quansight has been working on free-threading support for widely used open source packages, and with `numpy` now in good shape things are unblocked for `numexpr` support. We are happy to contribute, or if you'd prefer to do it yourself please do let us know.

Here is what at least will need doing I think:

- [x] Audit C++ code for known issues (globals, caches, etc.)
- [x] Test with `pytest-run-parallel` to smoke out some classes of issues
- [x] Declare free-threading support in extension modules (see https://py-free-threading.github.io/porting/#declaring-free-threaded-support)
- [x] Add `cp313t` to wheel build CI jobs

In addition, there are threading-related APIs in `numexpr`, like `set_num_threads` and the corresponding environment variables (`NUMEXPR_MAX_THREADS`, `OMP_NUM_THREADS`). If the package or end user which is using/calling `numexpr` doesn't use the Python `threading` module (usage is rare currently), then there's probably nothing to worry about. If it does use `threading`, there's a risk of oversubscription of threads under free-threading, just like when one would use `multiprocessing` today on a default (with-gil) CPython build. I'm not sure that there's anything to do here, but it's worth thinking about. 
author_association: NONE
user: login: FrancescAlted
created_at: 2025-02-18T10:32:59Z
body: Hi Ralf.  We are currently busy, so we would be happy if you want to proceed and contribute a PR.

Regarding oversubscription, yeah, I can see the issue; is there a way to detect whether we are in the thread-free python interpreter?  This way we could at least raise a warning about it.

FWIW, there is also: https://github.com/pydata/numexpr/issues/502.  While you are at work with this one, you may be interesting in fixing that too; up to you.
author_association: CONTRIBUTOR
user: login: rgommers
created_at: 2025-02-20T21:48:39Z
body: Thanks Francesc.

>  I can see the issue; is there a way to detect whether we are in the thread-free python interpreter? This way we could at least raise a warning about it.

There is no public Python API for this, since free-threading is supposed to be temporary and become the default (and only) Python implementation in a few years. That said, there is certainly a need right now. The standard way we've been doing this detection (from https://py-free-threading.github.io/running-gil-disabled/) is:
```python
is_freethreaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
```

A warning seems reasonable if oversubscription is detected. The more tricky part is probably to detect whether multiple Python threads are being used at the level above `numexpr`. I'm not sure how to do this. It's the same puzzle as with `multiprocessing`. In NumPy we recommend using [threadpoolctl](https://github.com/joblib/threadpoolctl) to control threading behavior, since we can't do it well within NumPy. Maybe that's the way to go here? 
author_association: NONE
user: login: FrancescAlted
created_at: 2025-02-21T06:04:31Z
body: > Thanks Francesc.
> 
> > I can see the issue; is there a way to detect whether we are in the thread-free python interpreter? This way we could at least raise a warning about it.
> 
> There is no public Python API for this, since free-threading is supposed to be temporary and become the default (and only) Python implementation in a few years. That said, there is certainly a need right now. The standard way we've been doing this detection (from https://py-free-threading.github.io/running-gil-disabled/) is:
> 
> is_freethreaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
> A warning seems reasonable if oversubscription is detected.

Ok, so my vote is to use this for now.

> The more tricky part is probably to detect whether multiple Python threads are being used at the level above `numexpr`. I'm not sure how to do this. It's the same puzzle as with `multiprocessing`. In NumPy we recommend using [threadpoolctl](https://github.com/joblib/threadpoolctl) to control threading behavior, since we can't do it well within NumPy. Maybe that's the way to go here?

Maybe. I didn't know about threadpoolctl, but feel free to add some notes in docs (like README) to suggest users going that way.
author_association: CONTRIBUTOR
user: login: rgommers
created_at: 2025-03-19T05:03:23Z
body: I checked off all the TODOs in my comment higher up, they were all done in gh-504 (merged as part of gh-505). Follow-up actions from that are:

- [x] Fix an issue with `init_sentinels_done` (xref [gh-504#comment](https://github.com/pydata/numexpr/pull/504#issuecomment-2733998457))
- [x] Migrate wheel build jobs to native Linux aarch64 runners and start building `cp313t` aarch64 wheels (done now in gh-507)
- [x] Docs: add a small script using threading in Python code in combination with the new `numexpr`. And perhaps, if it helps demonstrate speedups, add a pandas (using numexpr under the hood) example. (xref [gh-504#comment](https://github.com/pydata/numexpr/pull/504#issuecomment-2709900830)) EDIT: done in gh-508.


author_association: NONE
user: login: andfoy
created_at: 2025-03-19T15:59:58Z
body: > fix an issue with init_sentinels_done

This one should be checked, the revert that @FrancescAlted did was because of some leftover code that I didn't remove when I was trying to check the locality of that variable
author_association: CONTRIBUTOR
user: login: rgommers
created_at: 2025-03-19T16:46:11Z
body: > This one should be checked, the revert that [@FrancescAlted](https://github.com/FrancescAlted) did was because of some leftover code that I didn't remove when I was trying to check the locality of that variable

So it got merged in gh-505? Or no changes are needed because there was never a problem to begin with?
author_association: NONE
user: login: andfoy
created_at: 2025-03-19T20:19:53Z
body: No changes are needed regarding that item, thanks for the clarification question @rgommers 
author_association: CONTRIBUTOR
user: login: FrancescAlted
created_at: 2025-03-25T16:32:51Z
body: Do you plan to continue with this anytime soon?  I am trying to figure out  whether I should do a release o wait a bit more.  If you think that say, in a couple of weeks you can finish, I'll wait.  Thanks!
author_association: CONTRIBUTOR
user: login: andfoy
created_at: 2025-03-25T17:01:06Z
body: I'm going to open a PR regarding docs today, as well for `aarch64` native builds
author_association: CONTRIBUTOR
user: login: andfoy
created_at: 2025-03-26T17:02:31Z
body: @FrancescAlted could this benchmark script already exemplify using numexpr using threads? The only change required would be setting `NUMEXPR_NUM_THREADS=1` https://github.com/pydata/numexpr/blob/master/bench/large_array_vs_numpy.py 
author_association: CONTRIBUTOR
user: login: FrancescAlted
created_at: 2025-03-26T17:15:48Z
body: Doh, is that working for you?  I am getting errors of this kind:

```
> NUMEXPR_NUM_THREADS=1 python bench/large_array_vs_numpy.py                             (py3.13)
Benchmarking Expression 1:
NumPy time (threaded over 32 chunks with 2 threads): 1.361437 seconds
Exception in thread Thread-4 (benchmark_numexpr_re_evaluate):
Traceback (most recent call last):
  File "/Users/faltet/miniforge3/envs/py3.13/lib/python3.13/threading.py", line 1041, in _bootstrap_inner
    self.run()
    ~~~~~~~~^^
  File "/Users/faltet/miniforge3/envs/py3.13/lib/python3.13/threading.py", line 992, in run
    self._target(*self._args, **self._kwargs)
    ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/faltet/blosc/numexpr/bench/large_array_vs_numpy.py", line 96, in benchmark_numexpr_re_evaluate
    time_taken = timeit.timeit(
        lambda: ne.re_evaluate(
    ...<2 lines>...
        number=num_runs,
    )
  File "/Users/faltet/miniforge3/envs/py3.13/lib/python3.13/timeit.py", line 237, in timeit
    return Timer(stmt, setup, timer, globals).timeit(number)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/Users/faltet/miniforge3/envs/py3.13/lib/python3.13/timeit.py", line 180, in timeit
    timing = self.inner(it, self.timer)
  File "<timeit-src>", line 6, in inner
  File "/Users/faltet/blosc/numexpr/bench/large_array_vs_numpy.py", line 97, in <lambda>
    lambda: ne.re_evaluate(
            ~~~~~~~~~~~~~~^
        local_dict={"a": a[start:end], "b": b[start:end], "c": c[start:end]}
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ),
    ^
  File "/Users/faltet/blosc/numexpr/numexpr/necompiler.py", line 1010, in re_evaluate
    args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
  File "/Users/faltet/blosc/numexpr/numexpr/necompiler.py", line 761, in getArguments
    for name in names:
                ^^^^^
TypeError: 'NoneType' object is not iterable
numexpr time (threaded with re_evaluate over 32 chunks with 2 threads): 0.993288 seconds
numexpr speedup: 1.37x
```
author_association: CONTRIBUTOR
user: login: andfoy
created_at: 2025-03-26T18:26:32Z
body: Let me check!
author_association: CONTRIBUTOR
user: login: andfoy
created_at: 2025-03-26T21:28:28Z
body: @FrancescAlted the issue boils down to 

https://github.com/pydata/numexpr/blob/d296d274b894c613d0422123cef51c87d94a0813/bench/large_array_vs_numpy.py#L94

Previously, as the cache was global, different threads could access the results left by past threads, however, since the cache is now thread-local, each thread needs to recompute their values independently.
author_association: CONTRIBUTOR
user: login: FrancescAlted
created_at: 2025-03-27T13:30:00Z
body: Ok.  Then feel free to modify the benchmark to follow the new behavior.  The idea is to have something that users can find educative for the new functionality.
author_association: CONTRIBUTOR
user: login: rgommers
created_at: 2025-05-25T10:09:37Z
body: > I am trying to figure out whether I should do a release o wait a bit more.

@FrancescAlted I thought I'd ask about this release. No real hurry for us, but it'd be nice to see free-threading support released (and handle follow-up in case an issue gets reported).
author_association: NONE
user: login: FrancescAlted
created_at: 2025-05-25T11:19:40Z
body: Yeah, it is on my TODO list, but the last month was pretty crazy. Hopefully in a couple of weeks, where my schedule looks more tranquil. 
author_association: CONTRIBUTOR
user: login: rgommers
created_at: 2025-05-25T11:29:04Z
body: Thanks Francesc!
author_association: NONE

Diff:
diff --git a/bench/free_threading.py b/bench/free_threading.py
new file mode 100644
index 0000000..cd00e78
--- /dev/null
+++ b/bench/free_threading.py
@@ -0,0 +1,171 @@
+#################################################################################
+# To compare the performance of numexpr when free-threading CPython is used.
+#
+# This example makes use of Python threads, as opposed to C native ones
+# in order to highlight the improvement introduced by free-threading CPython,
+# which now disables the GIL altogether.
+#################################################################################
+"""
+Results with GIL-enabled CPython:
+
+Benchmarking Expression 1:
+NumPy time (threaded over 32 chunks with 16 threads): 1.173090 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 0.951071 seconds
+numexpr speedup: 1.23x
+----------------------------------------
+Benchmarking Expression 2:
+NumPy time (threaded over 32 chunks with 16 threads): 10.410874 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 8.248753 seconds
+numexpr speedup: 1.26x
+----------------------------------------
+Benchmarking Expression 3:
+NumPy time (threaded over 32 chunks with 16 threads): 9.605909 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 11.087108 seconds
+numexpr speedup: 0.87x
+----------------------------------------
+Benchmarking Expression 4:
+NumPy time (threaded over 32 chunks with 16 threads): 3.836962 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 18.054531 seconds
+numexpr speedup: 0.21x
+----------------------------------------
+
+Results with free-threading CPython:
+
+Benchmarking Expression 1:
+NumPy time (threaded over 32 chunks with 16 threads): 3.415349 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 2.618876 seconds
+numexpr speedup: 1.30x
+----------------------------------------
+Benchmarking Expression 2:
+NumPy time (threaded over 32 chunks with 16 threads): 19.005238 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 12.611407 seconds
+numexpr speedup: 1.51x
+----------------------------------------
+Benchmarking Expression 3:
+NumPy time (threaded over 32 chunks with 16 threads): 20.555149 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 17.690749 seconds
+numexpr speedup: 1.16x
+----------------------------------------
+Benchmarking Expression 4:
+NumPy time (threaded over 32 chunks with 16 threads): 38.338372 seconds
+numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 35.074684 seconds
+numexpr speedup: 1.09x
+----------------------------------------
+"""
+
+import os
+
+os.environ["NUMEXPR_NUM_THREADS"] = "2"
+import threading
+import timeit
+
+import numpy as np
+
+import numexpr as ne
+
+array_size = 10**8
+num_runs = 10
+num_chunks = 32  # Number of chunks
+num_threads = 16  # Number of threads constrained by how many chunks memory can hold
+
+a = np.random.rand(array_size).reshape(10**4, -1)
+b = np.random.rand(array_size).reshape(10**4, -1)
+c = np.random.rand(array_size).reshape(10**4, -1)
+
+chunk_size = array_size // num_chunks
+
+expressions_numpy = [
+    lambda a, b, c: a + b * c,
+    lambda a, b, c: a**2 + b**2 - 2 * a * b * np.cos(c),
+    lambda a, b, c: np.sin(a) + np.log(b) * np.sqrt(c),
+    lambda a, b, c: np.exp(a) + np.tan(b) - np.sinh(c),
+]
+
+expressions_numexpr = [
+    "a + b * c",
+    "a**2 + b**2 - 2 * a * b * cos(c)",
+    "sin(a) + log(b) * sqrt(c)",
+    "exp(a) + tan(b) - sinh(c)",
+]
+
+
+def benchmark_numpy_chunk(func, a, b, c, results, indices):
+    for index in indices:
+        start = index * chunk_size
+        end = (index + 1) * chunk_size
+        time_taken = timeit.timeit(
+            lambda: func(a[start:end], b[start:end], c[start:end]), number=num_runs
+        )
+        results.append(time_taken)
+
+
+def benchmark_numexpr_re_evaluate(expr, a, b, c, results, indices):
+    for index in indices:
+        start = index * chunk_size
+        end = (index + 1) * chunk_size
+        # if index == 0:
+        # Evaluate the first chunk with evaluate
+        time_taken = timeit.timeit(
+            lambda: ne.evaluate(
+                expr,
+                local_dict={
+                    "a": a[start:end],
+                    "b": b[start:end],
+                    "c": c[start:end],
+                },
+            ),
+            number=num_runs,
+        )
+        results.append(time_taken)
+
+
+def run_benchmark_threaded():
+    chunk_indices = list(range(num_chunks))
+
+    for i in range(len(expressions_numpy)):
+        print(f"Benchmarking Expression {i+1}:")
+
+        results_numpy = []
+        results_numexpr = []
+
+        threads_numpy = []
+        for j in range(num_threads):
+            indices = chunk_indices[j::num_threads]  # Distribute chunks across threads
+            thread = threading.Thread(
+                target=benchmark_numpy_chunk,
+                args=(expressions_numpy[i], a, b, c, results_numpy, indices),
+            )
+            threads_numpy.append(thread)
+            thread.start()
+
+        for thread in threads_numpy:
+            thread.join()
+
+        numpy_time = sum(results_numpy)
+        print(
+            f"NumPy time (threaded over {num_chunks} chunks with {num_threads} threads): {numpy_time:.6f} seconds"
+        )
+
+        threads_numexpr = []
+        for j in range(num_threads):
+            indices = chunk_indices[j::num_threads]  # Distribute chunks across threads
+            thread = threading.Thread(
+                target=benchmark_numexpr_re_evaluate,
+                args=(expressions_numexpr[i], a, b, c, results_numexpr, indices),
+            )
+            threads_numexpr.append(thread)
+            thread.start()
+
+        for thread in threads_numexpr:
+            thread.join()
+
+        numexpr_time = sum(results_numexpr)
+        print(
+            f"numexpr time (threaded with re_evaluate over {num_chunks} chunks with {num_threads} threads): {numexpr_time:.6f} seconds"
+        )
+        print(f"numexpr speedup: {numpy_time / numexpr_time:.2f}x")
+        print("-" * 40)
+
+
+if __name__ == "__main__":
+    run_benchmark_threaded()
diff --git a/numexpr/necompiler.py b/numexpr/necompiler.py
index 4ada878..537f816 100644
--- a/numexpr/necompiler.py
+++ b/numexpr/necompiler.py
@@ -775,14 +775,12 @@ def getArguments(names, local_dict=None, global_dict=None, _frame_depth: int=2):
 
 
 # Dictionaries for caching variable names and compiled expressions
-# _names_cache = CacheDict(256)
 _names_cache = threading.local()
-# _numexpr_cache = CacheDict(256)
 _numexpr_cache = threading.local()
-# _numexpr_last = ContextDict()
 _numexpr_last = threading.local()
 evaluate_lock = threading.Lock()
 
+
 def validate(ex: str,
              local_dict: Optional[Dict] = None,
              global_dict: Optional[Dict] = None,
@@ -856,7 +854,6 @@ def validate(ex: str,
     ----
 
     """
-    global _numexpr_last
     if not hasattr(_numexpr_last, 'l'):
         _numexpr_last.l = ContextDict()
 
@@ -998,7 +995,6 @@ def re_evaluate(local_dict: Optional[Dict] = None,
         The calling frame depth. Unless you are a NumExpr developer you should
         not set this value.
     """
-    global _numexpr_last
     if not hasattr(_numexpr_last, 'l'):
         _numexpr_last.l = ContextDict()
 
@@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
+    return compiled_ex(*args, **kwargs)


Reviews:
user: login: andfoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T17:07:08Z
user: login: andfoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T17:07:41Z
user: login: andfoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T17:08:08Z
user: login: FrancescAlted
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T15:56:16Z
user: login: andfoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T20:54:46Z
user: login: FrancescAlted
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:17:46Z
user: login: andfoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:46:18Z

Review comments:
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: andfoy
body: @FrancescAlted, it seems that this lock is a performance bottleneck when multiple threads are ran in parallel, is it necessary?
created_at: 2025-03-27T17:07:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: andfoy
body: With the lock:

```
Benchmarking Expression 1:
NumPy time (threaded over 32 chunks with 16 threads): 3.276298 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 9.975059 seconds
numexpr speedup: 0.33x
----------------------------------------
Benchmarking Expression 2:
NumPy time (threaded over 32 chunks with 16 threads): 18.981946 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 50.327974 seconds
numexpr speedup: 0.38x
----------------------------------------
Benchmarking Expression 3:
NumPy time (threaded over 32 chunks with 16 threads): 20.414158 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 70.900648 seconds
numexpr speedup: 0.29x
----------------------------------------
Benchmarking Expression 4:
NumPy time (threaded over 32 chunks with 16 threads): 38.012808 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 160.476216 seconds
numexpr speedup: 0.24x
----------------------------------------
```
created_at: 2025-03-27T17:07:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: andfoy
body: Without locking:

```
Benchmarking Expression 1:
NumPy time (threaded over 32 chunks with 16 threads): 3.415349 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 2.618876 seconds
numexpr speedup: 1.30x
----------------------------------------
Benchmarking Expression 2:
NumPy time (threaded over 32 chunks with 16 threads): 19.005238 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 12.611407 seconds
numexpr speedup: 1.51x
----------------------------------------
Benchmarking Expression 3:
NumPy time (threaded over 32 chunks with 16 threads): 20.555149 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 17.690749 seconds
numexpr speedup: 1.16x
----------------------------------------
Benchmarking Expression 4:
NumPy time (threaded over 32 chunks with 16 threads): 38.338372 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 35.074684 seconds
numexpr speedup: 1.09x
----------------------------------------

```
created_at: 2025-03-27T17:08:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: FrancescAlted
body: Uh, that was introduced long ago, so I don't remember at all; but if it is there, I'd say that it is necessary, yes. Just to double check, what happens to the test suite if you remove the lock?
created_at: 2025-03-31T15:56:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: andfoy
body: Tests are passing locally and on CI
created_at: 2025-03-31T20:54:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: FrancescAlted
body: Yep, I did my tests locally on a series of packages that depend on numexpr, and everything seems fine with removing the lock.  So, feel free in proceeding with the lock removal.
created_at: 2025-04-01T12:17:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1009,5 +1005,5 @@ def re_evaluate(local_dict: Optional[Dict] = None,
     argnames = _numexpr_last.l['argnames']
     args = getArguments(argnames, local_dict, global_dict, _frame_depth=_frame_depth)
     kwargs = _numexpr_last.l['kwargs']
-    with evaluate_lock:
-        return compiled_ex(*args, **kwargs)
+    # with evaluate_lock:
path: numexpr/necompiler.py
user: login: andfoy
body: Maybe the lock was in place to prevent concurrent access to the caches, which shouldn't be much of an issue now, given they were made thread-local
created_at: 2025-04-01T16:46:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: FrancescAlted
created_at: 2025-04-01T12:32:28Z
body: FWIW, this is what I am seeing with my regular python:

```
(py3.13) faltet@bigdaddy:~/blosc/numexpr$ python bench/free_threading.py
Benchmarking Expression 1:
NumPy time (threaded over 32 chunks with 16 threads): 1.173090 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 0.951071 seconds
numexpr speedup: 1.23x
----------------------------------------
Benchmarking Expression 2:
NumPy time (threaded over 32 chunks with 16 threads): 10.410874 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 8.248753 seconds
numexpr speedup: 1.26x
----------------------------------------
Benchmarking Expression 3:
NumPy time (threaded over 32 chunks with 16 threads): 9.605909 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 11.087108 seconds
numexpr speedup: 0.87x
----------------------------------------
Benchmarking Expression 4:
NumPy time (threaded over 32 chunks with 16 threads): 3.836962 seconds
numexpr time (threaded with re_evaluate over 32 chunks with 16 threads): 18.054531 seconds
numexpr speedup: 0.21x
----------------------------------------
```

I suppose that running against Python 3.13t (with free-threading enabled), will reproduce your conditions.  Could you please update the instructions at the beginning of the benchmark so as to stress this out?  Also, adding a link to a free-threaded Python explanation/discussion would be great.
author_association: CONTRIBUTOR
user: login: FrancescAlted
created_at: 2025-04-04T10:09:58Z
body: How is this going?  Do you need some more time for getting this ready?
author_association: CONTRIBUTOR
user: login: andfoy
created_at: 2025-04-07T16:40:51Z
body: @FrancescAlted, today I'll add the missing changes!
author_association: CONTRIBUTOR
user: login: FrancescAlted
created_at: 2025-04-09T03:22:04Z
body: LGTM
author_association: CONTRIBUTOR
user: login: FrancescAlted
created_at: 2025-04-09T03:23:30Z
body: Thanks @andfoy !  Expect a release soon.  In case you need to contribute something more, feel free :-)
author_association: CONTRIBUTOR