Skip to content

Release the GIL around the TA-Lib C calls - #763

Open
meet-brad-ch wants to merge 1 commit into
TA-Lib:masterfrom
meet-brad-ch:nogil
Open

meet-brad-ch wants to merge 1 commit into
TA-Lib:masterfrom
meet-brad-ch:nogil

Conversation

@meet-brad-ch

Copy link
Copy Markdown

Every generated wrapper in _func.pxi and _stream.pxi now runs its one C call inside with nogil:. The declarations in _ta_lib.pxd carry nogil on the ta_func.h block so Cython allows it. The two generators emit the block, so the next make generate keeps it. Nothing else changes: input checks, output allocation, the NaN prefix and the return-code check still run under the GIL, before and after the call.

This is the change asked for in #128 in 2016 and in the recent discussion on the TA-Lib side: TA-Lib/ta-lib#414 (comment).

Why it is safe

  • The call line touches no Python object. Every argument is a C pointer, int or double: <double *>(real.data)+begidx, the cleaned parameter names, &outbegidx, &outnbelement, and the output pointers. I checked the generated code for single-output, multi-output (MACD), integer-output (CDLDOJI), array-parameter (MAVP) and streaming functions.
  • ndarray.data is declared in Cython's numpy .pxd as cdef inline char* data(self) nogil and compiles to PyArray_BYTES, a struct read. The generated C for TA_SMA is TA_SMA(0, endidx, ((double *)__pyx_f_5numpy_7ndarray_4data_data(real)) + begidx, ...), with no Python API call in between.
  • On the stale-pointer question from is it possible to take the advantage of the cython nogil keyword? #128: CPython never moves objects or their buffers. The input arrays are the function's arguments and the output arrays are its locals. Every buffer stays referenced for the whole call and cannot be freed under it. This is the same guarantee typed memoryviews rely on inside with nogil.
  • The TA-Lib C library supports it. ta_global.h states that init and shutdown are multithread-protected, and the indicator functions only read the global settings.

Threading model, now stated in the README: indicator calls may run concurrently from any number of threads. _ta_initialize, _ta_shutdown, set_unstable_period, set_compatibility, _ta_set_candle_settings and _ta_restore_candle_default_settings stay under the GIL. Changing a setting while calls run in other threads is undefined behavior.

Test: tests/test_threads.py runs EMA, RSI, MACD, CDLDOJI, ATR, the streaming EMA and the abstract BBANDS from 8 threads for 50 rounds each. It checks every result against the single-threaded one with assert_array_equal. A second test changes an unstable period between two concurrent batches and checks that every later call sees it.

Numbers, same machine (24 threads, Windows, Python 3.12, numpy 2.5, polars 1.44), median of 7:

before after
8 threads, each EMA on its own 1M-row array, wall time 21.3 ms (serial 21.0) 6.8 ms (serial 20.4)
Polars map_batches(talib.EMA), 8 indicators in one select, 1M rows 34.6 ms 13.0 ms
Polars map_batches(talib.EMA), 8 indicators in one select, 10M rows 324 ms 127 ms
one indicator, 1M rows 2.7 ms 2.7 ms

Single calls do not change. Anything that calls from several threads now overlaps.

About the diff

  • talib/_ta_lib.c is regenerated with Cython 3.2.8, the version of the committed file. Its diff is large for two reasons. Every function grew by one line and Cython tracks line numbers, and each of the 322 calls gained the GIL release and acquire.
  • _func.pxi and _stream.pxi carry exactly the transformation the generators now emit, applied to the committed files. Running make generate produces the same content and additionally swaps the order of AVGDEV and AVGPRICE. That swap predates this change and is left out to keep the diff readable.
  • The two nogil markers that TA_RSI and TA_RSI_Lookback already had are removed, since the whole block now carries it.

Every generated wrapper runs its one C call inside `with nogil:`, so calls from several
threads run concurrently. The declarations carry `nogil` on the ta_func.h block, the two
generators emit the block, and _ta_lib.c is regenerated with Cython 3.2.8. Input checks,
output allocation and the return-code check stay under the GIL. Initialization, shutdown
and the global settings stay under the GIL too; the README states the model.

A new test runs seven functions across the three APIs from eight threads and checks every
result against the single-threaded one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant