Repository: cython/cython

Issue:
[ENH] parallel/prange(..., if=N>100) and friends
### Is your feature request related to a problem? Please describe.

In my code, I would like to only create parallel/openmp regions for simple/loop constructs with a certain size.  
For instance the overhead of calling a loop construct with 10 elements (with very little work on each iteration) can be quite big.
This is already enabled by the openmp standard since 2
```cython
with parallel(if=N>1000):
    ... code as normal

# add use case related code here
for i in prange(N, if=N>1000):
    ... code as normal
```

This has been in the OpenMP standard since 1.0 and is a simple:
```c
#pragma omp parallel if(<condition>)
...
```
so shouldn't be too hard since everything will be handled by the compiler when setting up the parallel regions.

### Describe the solution you'd like.

The only problem is that `if` is a reserved keyword, so it should be named differently:
- `par_if`
- `IF`
- `when`
- ...?


### Describe alternatives you've considered.

_No response_

### Additional context

I have tried to look into the Cython code to see if I could add the pragma declaration, but it was sadly a bit too hard for me... :(


Discussion:
This sounds reasonable. It's probably medium difficulty to implement. I think we'd just call the argument `if_` - that sort of name is fairly common elsewhere in the Python world.
Sounds great!
Don't read that as a promise to do it though! Just that it sounds like a nice feature if someone did do it!
I actually managed to do something, could somebody tell me if I am on the right track:
```patch
diff --git a/Cython/Compiler/Nodes.py b/Cython/Compiler/Nodes.py
index 877e7b928..35547dc8b 100644
--- a/Cython/Compiler/Nodes.py
+++ b/Cython/Compiler/Nodes.py
@@ -8981,7 +8981,7 @@ class ParallelStatNode(StatNode, ParallelNode):
                                 construct (replaced by its compile time value)
     """
 
-    child_attrs = ['body', 'num_threads']
+    child_attrs = ['body', 'num_threads', 'if_']
 
     body = None
 
@@ -8992,6 +8992,7 @@ class ParallelStatNode(StatNode, ParallelNode):
 
     num_threads = None
     chunksize = None
+    if_ = None
 
     parallel_exc = (
         Naming.parallel_exc_type,
@@ -9034,9 +9035,10 @@ class ParallelStatNode(StatNode, ParallelNode):
         self.body.analyse_declarations(env)
 
         self.num_threads = None
+        self.if_ = None
 
         if self.kwargs:
-            # Try to find num_threads and chunksize keyword arguments
+            # Try to find num_threads, if_ and chunksize keyword arguments
             pairs = []
             seen = set()
             for dictitem in self.kwargs.key_value_pairs:
@@ -9046,6 +9048,9 @@ class ParallelStatNode(StatNode, ParallelNode):
                 if dictitem.key.value == 'num_threads':
                     if not dictitem.value.is_none:
                         self.num_threads = dictitem.value
+                elif dictitem.key.value == 'if_':
+                    if not dictitem.value.is_none:
+                        self.if_ = dictitem.value
                 elif self.is_prange and dictitem.key.value == 'chunksize':
                     if not dictitem.value.is_none:
                         self.chunksize = dictitem.value
@@ -9072,6 +9077,12 @@ class ParallelStatNode(StatNode, ParallelNode):
         if self.num_threads:
             self.num_threads = self.num_threads.analyse_expressions(env)
 
+        if self.if_:
+            if self.is_parallel:
+                self.if_ = self.if_.analyse_expressions(env)
+            else:
+                error(self.pos, "if_ must de declared in the parent parallel section")
+
         if self.chunksize:
             self.chunksize = self.chunksize.analyse_expressions(env)
 
@@ -9091,6 +9102,7 @@ class ParallelStatNode(StatNode, ParallelNode):
             if not self.num_threads.is_simple() or self.num_threads.type.is_pyobject:
                 self.num_threads = self.num_threads.coerce_to(
                     PyrexTypes.c_int_type, env).coerce_to_temp(env)
+
         return self
 
     def analyse_sharing_attributes(self, env):
@@ -9246,6 +9258,12 @@ class ParallelStatNode(StatNode, ParallelNode):
         if self.num_threads is not None:
             code.put(" num_threads(%s)" % self.evaluate_before_block(code, self.num_threads))
 
+    def put_if_(self, code):
+        """
+        Write self.if_ if set as the if OpenMP directive
+        """
+        if self.if_ is not None:
+            code.put(" if(%s)" % self.evaluate_before_block(code, self.if_))
 
     def declare_closure_privates(self, code):
         """
@@ -9681,9 +9699,10 @@ class ParallelWithBlockNode(ParallelStatNode):
     This node represents a 'with cython.parallel.parallel():' block
     """
 
-    valid_keyword_arguments = ['num_threads']
+    valid_keyword_arguments = ['num_threads', 'if_']
 
     num_threads = None
+    if_ = None
 
     def analyse_declarations(self, env):
         super(ParallelWithBlockNode, self).analyse_declarations(env)
@@ -9698,6 +9717,9 @@ class ParallelWithBlockNode(ParallelStatNode):
         code.putln("#ifdef _OPENMP")
         code.put("#pragma omp parallel ")
 
+        # Add if statement, if present
+        self.put_if_(code)
+
         if self.privates:
             privates = [e.cname for e in self.privates
                         if not e.type.is_pyobject]
@@ -9750,7 +9772,7 @@ class ParallelRangeNode(ParallelStatNode):
     nogil = None
     schedule = None
 
-    valid_keyword_arguments = ['schedule', 'nogil', 'num_threads', 'chunksize']
+    valid_keyword_arguments = ['schedule', 'nogil', 'num_threads', 'chunksize', 'if_']
 
     def __init__(self, pos, **kwds):
         super(ParallelRangeNode, self).__init__(pos, **kwds)
@@ -10001,6 +10023,10 @@ class ParallelRangeNode(ParallelStatNode):
             reduction_codepoint = self.parent.privatization_insertion_point
         else:
             code.put("#pragma omp parallel")
+
+            # Add if-statements to code
+            self.put_if_(code)
+
             self.privatization_insertion_point = code.insertion_point()
             reduction_codepoint = self.privatization_insertion_point
             code.putln("")
```
That definitely looks on the right track!
We'd want some test cases (which I guess you sort of have already). They'd ideally like with the other parallel tests in tests/run/parallel.pyx (or maybe one of the other similarly named file). Ideally also a short example for the documentation but that doesn't absolutely need to be in the initial PR
>I think we'd just call the argument `if_` - that sort of name is fairly
>common elsewhere in the Python world.

Sorry for the bike shedding, but requiring underscore names for keyword arguments seems unnecessarily annoying. I'm sure there's a nicer name. Maybe "only_if"? I'm not sure if that's better, but at least it doesn't look like we just failed to find a better name.

I partially agree, but the clear reference to the openmp specification is obvious when using this name. I will not object to any choice.  
But I would prefer a clear nomenclature resembling the openmp specification. 

I would also think that people trying out Cython would quite quickly realize that `if` is a reserved word, hence the _, it is not uncommon practice, take https://docs.python.org/3/library/operator.html as an example.
> I would also think that people trying out Cython would quite quickly realize that `if` is a reserved word, hence the _, it is not uncommon practice

Yes - that was largely my logic in suggesting it. Similar things seem to be the de-facto way of writing names that conflict with keywords (PyQt `exec_` was in my mind as an example too).

With that said, I don't feel too strongly and would be happy with other sensible options. If/when you have something you're close to happy with, don't delay submitting it because you're unsure of a name.

Resolving patch:
diff --git a/Cython/Compiler/Nodes.py b/Cython/Compiler/Nodes.py
--- a/Cython/Compiler/Nodes.py
+++ b/Cython/Compiler/Nodes.py
@@ -9083,7 +9083,7 @@ class ParallelStatNode(StatNode, ParallelNode):
                                 construct (replaced by its compile time value)
     """
 
-    child_attrs = ['body', 'num_threads']
+    child_attrs = ['body', 'num_threads', 'threading_condition']
 
     body = None
 
@@ -9094,6 +9094,7 @@ class ParallelStatNode(StatNode, ParallelNode):
 
     num_threads = None
     chunksize = None
+    threading_condition = None
 
     parallel_exc = (
         Naming.parallel_exc_type,
@@ -9136,9 +9137,10 @@ def analyse_declarations(self, env):
         self.body.analyse_declarations(env)
 
         self.num_threads = None
+        self.threading_condition = None
 
         if self.kwargs:
-            # Try to find num_threads and chunksize keyword arguments
+            # Try to find known keyword arguments.
             pairs = []
             seen = set()
             for dictitem in self.kwargs.key_value_pairs:
@@ -9148,6 +9150,9 @@ def analyse_declarations(self, env):
                 if dictitem.key.value == 'num_threads':
                     if not dictitem.value.is_none:
                         self.num_threads = dictitem.value
+                elif dictitem.key.value == 'use_threads_if':
+                    if not dictitem.value.is_none:
+                        self.threading_condition = dictitem.value
                 elif self.is_prange and dictitem.key.value == 'chunksize':
                     if not dictitem.value.is_none:
                         self.chunksize = dictitem.value
@@ -9174,6 +9179,12 @@ def analyse_expressions(self, env):
         if self.num_threads:
             self.num_threads = self.num_threads.analyse_expressions(env)
 
+        if self.threading_condition:
+            if self.is_parallel:
+                self.threading_condition = self.threading_condition.analyse_expressions(env)
+            else:
+                error(self.pos, "'use_threads_if' must de declared in the parent parallel section")
+
         if self.chunksize:
             self.chunksize = self.chunksize.analyse_expressions(env)
 
@@ -9777,15 +9788,23 @@ def redef_builtin_expect_apple_gcc_bug(self, code):
         if not self.parent:
             code.redef_builtin_expect(self.redef_condition)
 
+    def _parameters_nogil_check(self, env, names, nodes):
+        for name, node in zip(names, nodes):
+            if node is not None and node.type.is_pyobject:
+                error(node.pos, "%s may not be a Python object "
+                                "as we don't have the GIL" % name)
+
+
 
 class ParallelWithBlockNode(ParallelStatNode):
     """
     This node represents a 'with cython.parallel.parallel():' block
     """
 
-    valid_keyword_arguments = ['num_threads']
+    valid_keyword_arguments = ['num_threads', 'use_threads_if']
 
     num_threads = None
+    threading_condition = None
 
     def analyse_declarations(self, env):
         super().analyse_declarations(env)
@@ -9794,12 +9813,20 @@ def analyse_declarations(self, env):
                             "positional arguments")
 
     def generate_execution_code(self, code):
+
+        if self.threading_condition is not None:
+            self.threading_condition.generate_evaluation_code(code)
+
         self.declare_closure_privates(code)
         self.setup_parallel_control_flow_block(code)
 
         code.putln("#ifdef _OPENMP")
         code.put("#pragma omp parallel ")
 
+        if self.threading_condition is not None:
+            code.put("if(%s) " % self.threading_condition.result())
+
+
         if self.privates:
             privates = [e.cname for e in self.privates
                         if not e.type.is_pyobject]
@@ -9826,11 +9853,21 @@ def generate_execution_code(self, code):
         return_ = code.label_used(code.return_label)
 
         self.restore_labels(code)
+
+        # ------ cleanup ------
         self.end_parallel_control_flow_block(code, break_=break_,
                                              continue_=continue_,
                                              return_=return_)
+
+        if self.threading_condition is not None:
+            self.threading_condition.generate_disposal_code(code)
+            self.threading_condition.free_temps(code)
+
         self.release_closure_privates(code)
 
+    def nogil_check(self, env):
+        self._parameters_nogil_check(env, ['use_threads_if'], [self.threading_condition])
+
 
 class ParallelRangeNode(ParallelStatNode):
     """
@@ -9841,7 +9878,7 @@ class ParallelRangeNode(ParallelStatNode):
     """
 
     child_attrs = ['body', 'target', 'else_clause', 'args', 'num_threads',
-                   'chunksize']
+                   'chunksize', 'threading_condition']
 
     body = target = else_clause = args = None
 
@@ -9852,7 +9889,7 @@ class ParallelRangeNode(ParallelStatNode):
     nogil = None
     schedule = None
 
-    valid_keyword_arguments = ['schedule', 'nogil', 'num_threads', 'chunksize']
+    valid_keyword_arguments = ['schedule', 'nogil', 'num_threads', 'chunksize', 'use_threads_if']
 
     def __init__(self, pos, **kwds):
         super().__init__(pos, **kwds)
@@ -9966,12 +10003,9 @@ def analyse_expressions(self, env):
         return node
 
     def nogil_check(self, env):
-        names = 'start', 'stop', 'step', 'target'
-        nodes = self.start, self.stop, self.step, self.target
-        for name, node in zip(names, nodes):
-            if node is not None and node.type.is_pyobject:
-                error(node.pos, "%s may not be a Python object "
-                                "as we don't have the GIL" % name)
+        names = 'start', 'stop', 'step', 'target', 'use_threads_if'
+        nodes = self.start, self.stop, self.step, self.target, self.threading_condition
+        self._parameters_nogil_check(env, names, nodes)
 
     def generate_execution_code(self, code):
         """
@@ -10039,6 +10073,9 @@ def generate_execution_code(self, code):
 
             fmt_dict[name] = result
 
+        if self.threading_condition is not None:
+            self.threading_condition.generate_evaluation_code(code)
+
         fmt_dict['i'] = code.funcstate.allocate_temp(self.index_type, False)
         fmt_dict['nsteps'] = code.funcstate.allocate_temp(self.index_type, False)
 
@@ -10081,7 +10118,7 @@ def generate_execution_code(self, code):
 
         # And finally, release our privates and write back any closure
         # variables
-        for temp in start_stop_step + (self.chunksize,):
+        for temp in start_stop_step + (self.chunksize, self.threading_condition):
             if temp is not None:
                 temp.generate_disposal_code(code)
                 temp.free_temps(code)
@@ -10103,6 +10140,10 @@ def generate_loop(self, code, fmt_dict):
             reduction_codepoint = self.parent.privatization_insertion_point
         else:
             code.put("#pragma omp parallel")
+
+            if self.threading_condition is not None:
+                code.put(" if(%s)" % self.threading_condition.result())
+
             self.privatization_insertion_point = code.insertion_point()
             reduction_codepoint = self.privatization_insertion_point
             code.putln("")
diff --git a/docs/examples/userguide/parallelism/condition_sum.py b/docs/examples/userguide/parallelism/condition_sum.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/userguide/parallelism/condition_sum.py
@@ -0,0 +1,14 @@
+from cython.parallel import prange
+
+def psum(n: cython.int):
+
+    i: cython.int
+    sum: cython.int = 0
+
+    for i in prange(n, nogil=True, use_threads_if=n>1000):
+        sum += i
+
+    return sum
+
+psum(30)        # Executed sequentially
+psum(10000)     # Executed in parallel


Test patch:
diff --git a/tests/errors/e_cython_parallel.pyx b/tests/errors/e_cython_parallel.pyx
--- a/tests/errors/e_cython_parallel.pyx
+++ b/tests/errors/e_cython_parallel.pyx
@@ -149,6 +149,26 @@ with nogil, cython.parallel.parallel():
     with cython.parallel.parallel():
         pass
 
+cdef bint gil_function():
+    return True
+
+for i in prange(10, nogil=True, use_threads_if=gil_function()):
+    pass
+
+with nogil, parallel.parallel(use_threads_if=gil_function()):
+    pass
+
+def bar():
+
+    python_var = object()
+
+    cdef int i
+
+    for i in prange(10, nogil=True, use_threads_if=python_var):
+        pass
+
+    with nogil, parallel.parallel(use_threads_if=python_var):
+        pass
 
 _ERRORS = u"""
 3:8: cython.parallel.parallel is not a module
@@ -184,4 +204,8 @@ _ERRORS = u"""
 139:62: Chunksize not valid for the schedule runtime
 145:70: Calling gil-requiring function not allowed without gil
 149:33: Nested parallel with blocks are disallowed
+155:59: Calling gil-requiring function not allowed without gil
+158:57: Calling gil-requiring function not allowed without gil
+167:51: use_threads_if may not be a Python object as we don't have the GIL
+170:49: use_threads_if may not be a Python object as we don't have the GIL
 """
diff --git a/tests/run/parallel.pyx b/tests/run/parallel.pyx
--- a/tests/run/parallel.pyx
+++ b/tests/run/parallel.pyx
@@ -36,14 +36,31 @@ cdef int get_num_threads() noexcept with gil:
     print "get_num_threads called"
     return 3
 
-def test_num_threads():
+cdef bint check_size(int size) nogil:
+    return size > 5
+
+def test_num_threads(int size):
     """
-    >>> test_num_threads()
+    >>> test_num_threads(6)
     1
     get_num_threads called
     3
     get_num_threads called
     3
+    get_num_threads called
+    3
+    get_num_threads called
+    3
+    >>> test_num_threads(4)
+    1
+    get_num_threads called
+    1
+    get_num_threads called
+    1
+    get_num_threads called
+    1
+    get_num_threads called
+    1
     """
     cdef int dyn = openmp.omp_get_dynamic()
     cdef int num_threads
@@ -56,14 +73,27 @@ def test_num_threads():
 
     print num_threads
 
-    with nogil, cython.parallel.parallel(num_threads=get_num_threads()):
+    with nogil, cython.parallel.parallel(num_threads=get_num_threads(), use_threads_if=size > 5):
+        p[0] = openmp.omp_get_num_threads()
+
+    print num_threads
+
+    # Checks that temporary variables are released properly
+    with nogil, cython.parallel.parallel(num_threads=get_num_threads(), use_threads_if=check_size(size)):
         p[0] = openmp.omp_get_num_threads()
 
     print num_threads
 
     cdef int i
+    # Checks that temporary variables are released properly
+    for i in prange(1, nogil=True, num_threads=get_num_threads(), use_threads_if=check_size(size)):
+        p[0] = openmp.omp_get_num_threads()
+        break
+
+    print num_threads
+
     num_threads = 0xbad
-    for i in prange(1, nogil=True, num_threads=get_num_threads()):
+    for i in prange(1, nogil=True, num_threads=get_num_threads(), use_threads_if=size > 5):
         p[0] = openmp.omp_get_num_threads()
         break