Repository: apytypes/apytypes (language: python)

Issue:
Contexts are not concurrent (not thread safe)
Having a global context state makes all concurrency of APyTypes non-existent. We must find a new way to handle [Python contexts](https://docs.python.org/3/library/contextlib.html).

It's not that things aren't working at the moment, but we have cornered ourselves and cannot support any type of parallel computing as things are now.


Discussion / hints:
gmpy2 mentions the added support for thread-safe contexts in their 2.1 update, see [here](https://github.com/aleaxit/gmpy). So one can start looking there.
C++ `thread_local` storage duration, read more about it [here on cppreference](https://en.cppreference.com/w/cpp/language/storage_duration).

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bedf0563a..7780a04b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Fixed
 
+- APyTypes contexts now supports multi-threading.
+
 ### Changed
 
 ### Removed
@@ -120,8 +122,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [0.1.0] - 2024-05-15
 
-[unreleased]: https://github.com/apytypes/apytypes/compare/v0.2.1...HEAD
-[0.2.2]: https://github.com/apytypes/apytypes/releases/tag/v0.2.1
+[unreleased]: https://github.com/apytypes/apytypes/compare/v0.2.2...HEAD
+[0.2.2]: https://github.com/apytypes/apytypes/releases/tag/v0.2.2
 [0.2.1]: https://github.com/apytypes/apytypes/releases/tag/v0.2.1
 [0.2.0]: https://github.com/apytypes/apytypes/releases/tag/v0.2.0
 [0.1.0]: https://github.com/apytypes/apytypes/releases/tag/v0.1.0
diff --git a/src/apyfixed_util.cc b/src/apyfixed_util.cc
index 80b29ccbf..6d6c3fad2 100644
--- a/src/apyfixed_util.cc
+++ b/src/apyfixed_util.cc
@@ -1,10 +1,9 @@
-#include <cmath>
-#include <fmt/format.h>
-
 #include "apyfixed_util.h"
-#include "apytypes_util.h"
 #include "ieee754.h"
 
+#include <cmath>
+#include <fmt/format.h>
+
 // GMP should be included after all other includes
 #include "../extern/mini-gmp/mini-gmp.h"
 
diff --git a/src/apytypes_common.cc b/src/apytypes_common.cc
index 93b0d3dee..b9a12baf1 100644
--- a/src/apytypes_common.cc
+++ b/src/apytypes_common.cc
@@ -1,19 +1,20 @@
+#include "apytypes_common.h"
+#include "apyfloat_util.h"
+#include "apytypes_util.h"
+
 // Python object access through Pybind
 #include <nanobind/nanobind.h>
 namespace nb = nanobind;
 
-#include "apyfloat.h"
-#include "apyfloat_util.h"
-#include "apytypes_common.h"
-#include "apytypes_util.h"
 #include <random>
 
 /* ********************************************************************************** *
- * *                          Quantization context for APyFloat * *
+ * *                          Quantization context for APyFloat                     * *
  * ********************************************************************************** */
 
 // Global quantization mode
-static QuantizationMode global_quantization_mode_float = QuantizationMode::RND_CONV;
+thread_local static QuantizationMode global_quantization_mode_float
+    = QuantizationMode::RND_CONV;
 
 // Get the global quantization mode
 QuantizationMode get_float_quantization_mode()
@@ -59,11 +60,11 @@ void APyFloatQuantizationContext::exit_context()
  * ********************************************************************************** */
 
 // This creates a random seed on every program start.
-std::uint64_t quantization_seed = std::random_device {}();
+thread_local static std::uint64_t quantization_seed = std::random_device {}();
 
 // A random number engine is used instead of purely std::random_device so that runs can
 // be reproducible.
-std::mt19937_64 gen64(quantization_seed);
+thread_local static std::mt19937_64 gen64(quantization_seed);
 
 void set_float_quantization_seed(std::uint64_t seed)
 {
@@ -79,8 +80,8 @@ std::uint64_t random_number_float() { return gen64(); }
  * *                      Cast context for APyFixed                                 * *
  * ********************************************************************************** */
 
-// Global accumulator option (default value: std::nullopt)
-static APyFixedCastOption global_cast_option_fixed
+// Global fixed-point cast option, default value: { TRN, WRAP }
+thread_local static APyFixedCastOption global_cast_option_fixed
     = { QuantizationMode::TRN, OverflowMode::WRAP };
 
 APyFixedCastContext::APyFixedCastContext(
@@ -116,7 +117,8 @@ APyFixedCastOption get_fixed_cast_mode() { return global_cast_option_fixed; }
  * ********************************************************************************** */
 
 // Global accumulator option (default value: std::nullopt)
-static std::optional<APyFixedAccumulatorOption> global_accumulator_option_fixed;
+thread_local static std::optional<APyFixedAccumulatorOption>
+    global_accumulator_option_fixed = std::nullopt;
 
 // Retrieve the global accumulator mode
 std::optional<APyFixedAccumulatorOption> get_accumulator_mode_fixed()
@@ -162,7 +164,8 @@ void APyFixedAccumulatorContext::exit_context()
  * ********************************************************************************** */
 
 // Global accumulator option (default value: std::nullopt)
-static std::optional<APyFloatAccumulatorOption> global_accumulator_option_float;
+thread_local static std::optional<APyFloatAccumulatorOption>
+    global_accumulator_option_float = std::nullopt;
 
 // Retrieve the global accumulator mode
 std::optional<APyFloatAccumulatorOption> get_accumulator_mode_float()


Test patch:
diff --git a/lib/test/parallel/test_contexts.py b/lib/test/parallel/test_contexts.py
new file mode 100644
index 000000000..e4538a096
--- /dev/null
+++ b/lib/test/parallel/test_contexts.py
@@ -0,0 +1,169 @@
+##
+# Testing of concurrency support for the different APyTypes contexts
+#
+
+from typing import Optional, List
+from apytypes import (
+    APyFixed,
+    APyFixedAccumulatorContext,
+    APyFixedArray,
+    APyFixedCastContext,
+    APyFloat,
+    APyFloatAccumulatorContext,
+    APyFloatArray,
+    APyFloatQuantizationContext,
+    QuantizationMode,
+)
+import threading
+
+# #################################################################################### #
+# #                          Quantization context for APyFloat                       # #
+# #################################################################################### #
+
+
+def _test_apyfloat_quantization_context(success: Optional[List[bool]] = None):
+    a = APyFloat.from_float(0.25, exp_bits=2, man_bits=2)
+    b = APyFloat.from_float(2.50, exp_bits=2, man_bits=2)
+    assert (a + b).is_identical(APyFloat.from_float(3.00, exp_bits=2, man_bits=2))
+
+    with APyFloatQuantizationContext(QuantizationMode.RND_ZERO):
+        assert (a + b).is_identical(APyFloat.from_float(2.50, exp_bits=2, man_bits=2))
+    with APyFloatQuantizationContext(QuantizationMode.RND_CONV_ODD):
+        assert (a + b).is_identical(APyFloat.from_float(2.50, exp_bits=2, man_bits=2))
+    with APyFloatQuantizationContext(QuantizationMode.RND_INF):
+        assert (a + b).is_identical(APyFloat.from_float(3.00, exp_bits=2, man_bits=2))
+
+    if success is not None:
+        success[0] = True
+
+
+def test_apyfloat_quantization_context():
+    # Run test on main Python thread first
+    _test_apyfloat_quantization_context()
+
+    # Run the test on a new thread after entering a new accumulator context
+    with APyFloatQuantizationContext(QuantizationMode.RND_ZERO):
+        success = [False]
+        t = threading.Thread(
+            target=_test_apyfloat_quantization_context, args=(success,)
+        )
+        t.start()
+        t.join()
+        assert success[0]
+
+
+# #################################################################################### #
+# #                         Cast context for APyFixed                                # #
+# #################################################################################### #
+
+
+def _test_apyfixed_cast_context(success: Optional[List[bool]] = None):
+    a = APyFixed.from_float(1.5, int_bits=3, frac_bits=1)
+
+    assert a.cast(int_bits=3, frac_bits=0).is_identical(
+        APyFixed.from_float(1.0, int_bits=3, frac_bits=0)
+    )
+    assert a.cast(
+        int_bits=3, frac_bits=0, quantization=QuantizationMode.RND
+    ).is_identical(APyFixed.from_float(2.0, int_bits=3, frac_bits=0))
+
+    with APyFixedCastContext(QuantizationMode.RND):
+        assert a.cast(int_bits=3, frac_bits=0).is_identical(
+            APyFixed.from_float(2.0, int_bits=3, frac_bits=0)
+        )
+    with APyFixedCastContext(QuantizationMode.TRN):
+        assert a.cast(int_bits=3, frac_bits=0).is_identical(
+            APyFixed.from_float(1.0, int_bits=3, frac_bits=0)
+        )
+
+    if success is not None:
+        success[0] = True
+
+
+def test_apyfixed_cast_context():
+    # Run test on main Python thread first
+    _test_apyfixed_cast_context()
+
+    # Run the test on a new thread after entering a new accumulator context
+    with APyFixedCastContext(QuantizationMode.RND):
+        success = [False]
+        t = threading.Thread(target=_test_apyfixed_cast_context, args=(success,))
+        t.start()
+        t.join()
+        assert success[0]
+
+
+# #################################################################################### #
+# #                      Accumulator context for APyFixedArray                       # #
+# #################################################################################### #
+
+
+def _test_apyfixedarray_accumulator_context(success: Optional[List[bool]] = None):
+    a = APyFixedArray.from_float([0.25, 0.50], int_bits=1, frac_bits=2)
+    b = APyFixedArray.from_float([0.75, 0.25], int_bits=1, frac_bits=2)
+
+    assert (a @ b).is_identical(
+        APyFixedArray.from_float([0.3125], int_bits=3, frac_bits=4)
+    )
+
+    with APyFixedAccumulatorContext(int_bits=1, frac_bits=4):
+        assert (a @ b).is_identical(
+            APyFixedArray.from_float([0.3125], int_bits=1, frac_bits=4)
+        )
+    with APyFixedAccumulatorContext(int_bits=1, frac_bits=3):
+        assert (a @ b).is_identical(
+            APyFixedArray.from_float([0.25], int_bits=1, frac_bits=3)
+        )
+    with APyFixedAccumulatorContext(int_bits=1, frac_bits=2):
+        assert (a @ b).is_identical(
+            APyFixedArray.from_float([0.0], int_bits=1, frac_bits=2)
+        )
+
+    if success is not None:
+        success[0] = True
+
+
+def test_apyfixedarray_accumulator_context():
+    # Run test on main Python thread first
+    _test_apyfixedarray_accumulator_context()
+
+    # Run the test on a new thread after entering a new accumulator context
+    with APyFixedAccumulatorContext(int_bits=1, frac_bits=3):
+        success = [False]
+        t = threading.Thread(
+            target=_test_apyfixedarray_accumulator_context, args=(success,)
+        )
+        t.start()
+        t.join()
+        assert success[0]
+
+
+# #################################################################################### #
+# #                      Accumulator context for APyFloatArray                       # #
+# #################################################################################### #
+
+
+def _test_apyfloatarray_accumulator_context(success: Optional[List[bool]] = None):
+    a = APyFloatArray.from_float([0.25, 0.50], exp_bits=2, man_bits=3)
+    b = APyFloatArray.from_float([0.75, 0.25], exp_bits=2, man_bits=3)
+
+    assert (a @ b).is_identical(APyFloat.from_float(0.375, exp_bits=2, man_bits=3))
+    with APyFloatAccumulatorContext(exp_bits=10, man_bits=10):
+        assert (a @ b).is_identical(APyFloat.from_float(0.3125, exp_bits=2, man_bits=3))
+
+    if success is not None:
+        success[0] = True
+
+
+def test_apyfloatarray_accumulator_context():
+    # Run test on main Python thread first
+    _test_apyfloatarray_accumulator_context()
+
+    with APyFloatAccumulatorContext(exp_bits=10, man_bits=10):
+        success = [False]
+        t = threading.Thread(
+            target=_test_apyfloatarray_accumulator_context, args=(success,)
+        )
+        t.start()
+        t.join()
+        assert success[0]