Skip to content

Commit fdbbb03

Browse files
committed
Fast-path already attached Python regions
1 parent f82658e commit fdbbb03

3 files changed

Lines changed: 154 additions & 55 deletions

File tree

‎src/C/regions.jl‎

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,83 @@
11
const PyThreadStatePtr = Ptr{Cvoid}
22

3-
mutable struct ThreadState
4-
sem::Base.Semaphore
5-
tstate::PyThreadStatePtr
6-
end
7-
ThreadState() = ThreadState(Base.Semaphore(1), C_NULL)
8-
93
mutable struct TaskState
4+
# CPython state used by this task for the duration of its outermost region.
105
tstate::PyThreadStatePtr
6+
# Semaphore protecting `tstate`; `nothing` when the task is outside a session.
117
sem::Union{Nothing,Base.Semaphore}
8+
# Whether `tstate` is currently attached to the task's pinned OS thread.
129
attached::Bool
10+
# Julia thread to which the task is pinned while its session is active.
1311
tid::Int
12+
# Stickiness to restore when the task's outermost region or break finishes.
1413
oldsticky::Bool
1514
end
1615
TaskState() = TaskState(C_NULL, nothing, false, 0, false)
1716

17+
mutable struct ThreadState
18+
# Serializes tasks which use the persistent CPython state on this Julia thread.
19+
sem::Base.Semaphore
20+
# PythonCall-owned CPython state for this Julia thread, allocated lazily.
21+
tstate::PyThreadStatePtr
22+
# Task which currently holds `sem` with a CPython state attached, if any.
23+
task::Union{Nothing,Task}
24+
# State belonging to `task`; cached so nested regions need no task-local lookup.
25+
task_state::Union{Nothing,TaskState}
26+
end
27+
ThreadState() = ThreadState(Base.Semaphore(1), C_NULL, nothing, nothing)
28+
1829
const THREAD_STATE = Utils.OncePerThread{ThreadState}(ThreadState)
1930
const TASK_STATE = Utils.OncePerTask{TaskState}(TaskState)
2031

32+
# A region session starts at the outermost region entered by a Julia task and ends
33+
# when that region exits. The task is made sticky for the whole session: CPython
34+
# thread states must be restored on the same OS thread on which they were saved.
35+
# The corresponding ThreadState semaphore gives the task exclusive use of that
36+
# thread's persistent PyThreadState. A Python-originated call already has a CPython
37+
# state attached; that state is borrowed instead, but is protected by the same
38+
# semaphore so PythonCall's accounting remains identical.
39+
#
40+
# Nested regions merely return `:noop`. A break saves/detaches the CPython state,
41+
# clears the thread cache, and releases the semaphore, allowing other sticky tasks
42+
# to use this Julia thread while the original task yields. On return it reacquires
43+
# the semaphore before restoring both the CPython state and cache. Consequently the
44+
# cache is populated exactly while a task owns the semaphore with Python attached.
45+
# This invariant is what makes its lock-free fast path safe.
46+
#
47+
# An attached CPython state is also a still cheaper fast path for @pyregion itself.
48+
# Given that PythonCall is the only Julia-side entry to Python, an attached state
49+
# means either that an enclosing region already did the bookkeeping or that Python
50+
# called into Julia and lent us its state. In either case a nested region has no
51+
# transition to perform. Conversely, a region inside a break observes no attached
52+
# state and takes the full path below, reacquiring and restoring its task state.
53+
2154
current_tstate() = PyThreadState_GetUnchecked()
2255
has_tstate() = CTX.is_initialized && current_tstate() != C_NULL
2356

57+
# An attached task is pinned to this Julia thread and owns its semaphore, so the
58+
# thread-local cache cannot change underneath it. This makes nested regions avoid
59+
# the considerably more expensive OncePerTask lookup. The cache is cleared before
60+
# releasing the semaphore and restored after reacquiring it around a region break.
61+
function current_task_state(task::Task)
62+
ts = THREAD_STATE()
63+
return ts.task === task ? ts.task_state::TaskState : TASK_STATE()
64+
end
65+
66+
function set_task!(ts::ThreadState, task::Task, s::TaskState)
67+
ts.task = task
68+
ts.task_state = s
69+
return
70+
end
71+
72+
function clear_task!(ts::ThreadState)
73+
ts.task = nothing
74+
ts.task_state = nothing
75+
return
76+
end
77+
2478
function reset!(s::TaskState)
79+
# `oldsticky` is deliberately left alone: start_session! overwrites it before
80+
# the TaskState becomes observable as an active session again.
2581
s.tstate = C_NULL
2682
s.sem = nothing
2783
s.attached = false
@@ -30,6 +86,8 @@ function reset!(s::TaskState)
3086
end
3187

3288
function start_session!(task::Task, s::TaskState)
89+
# Pin before recording the thread and looking up its ThreadState. Once sticky,
90+
# cooperative scheduling cannot resume this task on another Julia thread.
3391
s.oldsticky = task.sticky
3492
task.sticky = true
3593
s.tid = Threads.threadid()
@@ -45,6 +103,8 @@ end
45103

46104
function enter_region(task::Task, s::TaskState)
47105
if s.tstate != C_NULL
106+
# An existing session is either already attached (ordinary nesting), or
107+
# temporarily detached because this region is nested inside a break.
48108
check_thread(task, s)
49109
if s.attached
50110
return :noop
@@ -53,6 +113,7 @@ function enter_region(task::Task, s::TaskState)
53113
try
54114
PyEval_RestoreThread(s.tstate)
55115
s.attached = true
116+
set_task!(THREAD_STATE(), task, s)
56117
catch
57118
Base.release(s.sem::Base.Semaphore)
58119
rethrow()
@@ -73,8 +134,10 @@ function enter_region(task::Task, s::TaskState)
73134
acquired = true
74135
current = current_tstate()
75136
if current != C_NULL
137+
# Calls originating in Python must return with its state still attached.
76138
s.tstate = current
77139
s.attached = true
140+
set_task!(ts, task, s)
78141
return :root_borrowed
79142
end
80143
if ts.tstate == C_NULL
@@ -84,6 +147,7 @@ function enter_region(task::Task, s::TaskState)
84147
s.tstate = ts.tstate
85148
PyEval_RestoreThread(s.tstate)
86149
s.attached = true
150+
set_task!(ts, task, s)
87151
return :root_owned
88152
catch
89153
acquired && Base.release(ts.sem)
@@ -94,23 +158,28 @@ function enter_region(task::Task, s::TaskState)
94158
end
95159

96160
function exit_region(task::Task, s::TaskState, token)
161+
# Tokens encode precisely which work enter_region performed, so every path
162+
# reverses only its own transition and nested :noop regions do no attach work.
97163
token === :noop && return
98164
check_thread(task, s)
99165
if token === :detach
100166
saved = PyEval_SaveThread()
101167
@assert saved == s.tstate
102168
s.attached = false
169+
clear_task!(THREAD_STATE())
103170
Base.release(s.sem::Base.Semaphore)
104171
elseif token === :root_owned
105172
saved = PyEval_SaveThread()
106173
@assert saved == s.tstate
107174
sem, oldsticky = s.sem::Base.Semaphore, s.oldsticky
175+
clear_task!(THREAD_STATE())
108176
reset!(s)
109177
Base.release(sem)
110178
task.sticky = oldsticky
111179
elseif token === :root_borrowed
112180
@assert current_tstate() == s.tstate
113181
sem, oldsticky = s.sem::Base.Semaphore, s.oldsticky
182+
clear_task!(THREAD_STATE())
114183
reset!(s)
115184
Base.release(sem)
116185
task.sticky = oldsticky
@@ -122,14 +191,18 @@ end
122191

123192
function enter_break(task::Task, s::TaskState)
124193
if s.tstate != C_NULL
194+
# Only the outermost break detaches. Further breaks while detached are no-ops.
125195
check_thread(task, s)
126196
!s.attached && return :noop
127197
saved = PyEval_SaveThread()
128198
@assert saved == s.tstate
129199
s.attached = false
200+
clear_task!(THREAD_STATE())
130201
Base.release(s.sem::Base.Semaphore)
131202
return :restore
132203
end
204+
# Outside a region, a break matters only in a Python-originated callback. It
205+
# borrows and detaches that state so yielding Julia code cannot retain Python.
133206
current_tstate() == C_NULL && return :noop
134207

135208
ts = try
@@ -170,8 +243,12 @@ function exit_break(task::Task, s::TaskState, token)
170243
Base.acquire(s.sem::Base.Semaphore)
171244
PyEval_RestoreThread(s.tstate)
172245
s.attached = true
246+
set_task!(THREAD_STATE(), task, s)
173247
if token === :root_restore
248+
# The borrowed Python state stays attached for the caller, but the temporary
249+
# Julia session and its cache ownership end here.
174250
sem, oldsticky = s.sem::Base.Semaphore, s.oldsticky
251+
clear_task!(THREAD_STATE())
175252
reset!(s)
176253
Base.release(sem)
177254
task.sticky = oldsticky
@@ -191,13 +268,19 @@ blocks cooperatively in [`@pyregionbreak`](@ref).
191268
"""
192269
macro pyregion(ex)
193270
quote
194-
local task = current_task()
195-
local state = $TASK_STATE()
196-
local token = $enter_region(task, state)
197-
try
271+
if $current_tstate() != C_NULL
272+
# The CPython TLS lookup is enough to prove that this region is nested
273+
# or Python-originated; avoid both Julia task- and thread-local lookups.
198274
$(esc(ex))
199-
finally
200-
$exit_region(task, state, token)
275+
else
276+
local task = current_task()
277+
local state = $current_task_state(task)
278+
local token = $enter_region(task, state)
279+
try
280+
$(esc(ex))
281+
finally
282+
$exit_region(task, state, token)
283+
end
201284
end
202285
end
203286
end
@@ -212,7 +295,7 @@ PythonCall operations still work automatically, and both kinds of region nest fr
212295
macro pyregionbreak(ex)
213296
quote
214297
local task = current_task()
215-
local state = $TASK_STATE()
298+
local state = $current_task_state(task)
216299
local token = $enter_break(task, state)
217300
try
218301
$(esc(ex))

‎test/C.jl‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,61 @@
1616
@test PythonCall.python_version().major == 3
1717
end
1818
end
19+
20+
@testitem "Python regions and task-safe thread states" setup = [Setup] begin
21+
using Base.Threads
22+
23+
@test PythonCall.C.PyThreadState_GetUnchecked() == C_NULL
24+
@test @pyregion pyconvert(Int, pyint(12)) == 12
25+
@test PythonCall.C.PyThreadState_GetUnchecked() == C_NULL
26+
27+
# Nested regions reuse the state cached by the owning Julia thread. A break
28+
# must relinquish that cache while yielding and restore it on return.
29+
@pyregion begin
30+
local state = PythonCall.C.TASK_STATE()
31+
local thread_state = PythonCall.C.THREAD_STATE()
32+
@test thread_state.task === current_task()
33+
@test thread_state.task_state === state
34+
@pyregion @test PythonCall.C.current_task_state(current_task()) === state
35+
@pyregionbreak begin
36+
@test thread_state.task === nothing
37+
yield()
38+
end
39+
@test thread_state.task === current_task()
40+
@test thread_state.task_state === state
41+
end
42+
@test PythonCall.C.THREAD_STATE().task === nothing
43+
44+
@test @pyregion begin
45+
@pyregion pyconvert(Int, pyint(1)) == 1
46+
@pyregionbreak begin
47+
yield()
48+
@pyregion pyconvert(Int, pyint(2)) == 2
49+
@pyregionbreak yield()
50+
end
51+
pyconvert(Int, pyint(3)) == 3
52+
end
53+
54+
@test_throws ErrorException @pyregion @pyregionbreak error("region exception")
55+
@test PythonCall.C.PyThreadState_GetUnchecked() == C_NULL
56+
@test_throws PyException @pyregion pybuiltins.int("not an integer")
57+
@test pyconvert(Int, pyint(5)) == 5
58+
59+
results = fetch.([@spawn begin
60+
total = 0
61+
for i in 1:100
62+
total += pyconvert(Int, pyint(i))
63+
@pyregionbreak yield()
64+
end
65+
total
66+
end for _ in 1:max(8, 2nthreads())])
67+
@test all(==(5050), results)
68+
69+
f = pyfunc() do
70+
yield()
71+
pyconvert(Int, pybuiltins.sum([1, 2, 3]))
72+
end
73+
@test pyconvert(Int, f()) == 6
74+
75+
@test pyconvert(Int, @py 1 + @jl(pyconvert(Int, pyint(2)))) == 3
76+
end

‎test/Region.jl‎

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)