Current section
Files
Jump to
Current section
Files
test/py_thread_affinity_SUITE.erl
%%% @doc Thread-affinity invariants for the per-context worker model.
%%%
%%% After the v3.0 simplification each context owns a dedicated pthread
%%% that handles all of its Python operations. These tests assert the
%%% invariants that motivated the rework so we don't regress numpy /
%%% torch / tensorflow thread-local state safety:
%%%
%%% - exec / eval / call on the same context all share one OS thread
%%% - calls from N different Erlang processes targeting the same
%%% context all converge on that context's worker thread
%%% - distinct contexts get distinct worker threads
%%% - the same invariants hold under owngil mode when supported
-module(py_thread_affinity_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([
all/0,
init_per_suite/1,
end_per_suite/1
]).
-export([
exec_eval_call_share_thread/1,
multi_process_share_context_thread/1,
distinct_contexts_distinct_threads/1,
owngil_thread_affinity/1
]).
all() ->
[
exec_eval_call_share_thread,
multi_process_share_context_thread,
distinct_contexts_distinct_threads,
owngil_thread_affinity
].
init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(erlang_python),
{ok, _} = py:start_contexts(),
Config.
end_per_suite(_Config) ->
ok = application:stop(erlang_python),
ok.
%%% ============================================================================
%%% Helpers
%%% ============================================================================
native_id(Ctx) ->
case py_context:eval(Ctx, <<"_pta_get_tid()">>, #{}) of
{ok, Tid} -> Tid;
Other -> ct:fail({native_id_failed, Other})
end.
setup_helper(Ctx) ->
ok = py_context:exec(Ctx, <<
"import threading\n"
"def _pta_get_tid():\n"
" return threading.get_native_id()\n"
>>).
%%% ============================================================================
%%% Tests
%%% ============================================================================
exec_eval_call_share_thread(_Config) ->
Ctx = py:context(1),
setup_helper(Ctx),
%% Stash a thread id from exec, then read it back via eval.
ok = py_context:exec(Ctx, <<
"import threading\n"
"_pta_exec_tid = threading.get_native_id()\n"
>>),
{ok, ExecTid} = py_context:eval(Ctx, <<"_pta_exec_tid">>, #{}),
EvalTid = native_id(Ctx),
{ok, CallTid} = py_context:call(Ctx, '__main__', '_pta_get_tid', []),
ct:pal("exec=~p eval=~p call=~p", [ExecTid, EvalTid, CallTid]),
ExecTid = EvalTid,
EvalTid = CallTid,
ok.
multi_process_share_context_thread(_Config) ->
Ctx = py:context(1),
setup_helper(Ctx),
Parent = self(),
N = 8,
Pids = [spawn(fun() ->
Tid = native_id(Ctx),
Parent ! {tid, self(), Tid}
end) || _ <- lists:seq(1, N)],
Tids = [receive {tid, Pid, T} -> T after 5000 -> ct:fail(timeout) end
|| Pid <- Pids],
ct:pal("tids = ~p", [Tids]),
[Single] = lists:usort(Tids),
true = is_integer(Single),
ok.
distinct_contexts_distinct_threads(_Config) ->
case py_context_router:num_contexts() of
N when N >= 2 ->
Ctx1 = py:context(1),
Ctx2 = py:context(2),
setup_helper(Ctx1),
setup_helper(Ctx2),
T1 = native_id(Ctx1),
T2 = native_id(Ctx2),
ct:pal("ctx1=~p ctx2=~p", [T1, T2]),
true = T1 =/= T2,
ok;
_ ->
{skip, "needs at least 2 contexts"}
end.
owngil_thread_affinity(_Config) ->
case py:subinterp_supported() of
false ->
{skip, "subinterpreters not supported on this Python build"};
true ->
case py_context:new(#{mode => owngil}) of
{error, owngil_requires_python314} ->
{skip, "owngil requires Python 3.14+"};
{error, Reason} ->
ct:fail({owngil_create_failed, Reason});
{ok, Ctx} ->
try
setup_helper(Ctx),
ok = py_context:exec(Ctx, <<
"import threading\n"
"_pta_exec_tid = threading.get_native_id()\n"
>>),
{ok, ExecTid} = py_context:eval(Ctx,
<<"_pta_exec_tid">>, #{}),
EvalTid = native_id(Ctx),
{ok, CallTid} = py_context:call(Ctx,
'__main__',
'_pta_get_tid', []),
ct:pal("owngil exec=~p eval=~p call=~p",
[ExecTid, EvalTid, CallTid]),
ExecTid = EvalTid,
EvalTid = CallTid,
Parent = self(),
Pids = [spawn(fun() ->
Tid = native_id(Ctx),
Parent ! {tid, self(), Tid}
end) || _ <- lists:seq(1, 4)],
Tids = [receive {tid, Pid, T} -> T
after 5000 -> ct:fail(timeout)
end || Pid <- Pids],
ct:pal("owngil multi-proc tids = ~p", [Tids]),
[Single] = lists:usort(Tids),
Single = ExecTid,
ok
after
py_context:stop(Ctx)
end
end
end.