Skip to content

Commit 8903f4c

Browse files
committed
Python runtime: keep waiting log lines in order without spinning
Follow-up to the change that made the bindings' log function stop running Python code. Lines that cannot go to Python at once wait for Python's main thread (a pending call) or for a thread of the bindings. A few cases were wrong: - The main thread could spin. When some of its lines waited for the bindings' thread and it logged again with sys.stderr replaced, its pending call put the lines back and asked to run again at every bytecode check, until the other thread finished. A Python loop then ran hundreds of times slower. Now such lines go behind the waiting ones to the bindings' thread, even if sys.stderr is replaced for a moment, since keeping the order matters more. - On the main thread, a binding call that ran in an except block right after a call that raised wrote its lines before the earlier lines still waiting for the pending call. Now a call on the main thread writes those lines first, then its own. - With fd 2 non-blocking and full, the bindings' thread dropped its lines when the write returned EAGAIN. Now it waits for room. - The bindings' thread wrote all its lines in one write, so on a pipe another thread's line could land inside one of them. Now it writes a line at a time, which keeps a line of up to PIPE_BUF bytes whole. - If that thread was still writing to fd 2 when Python finished exiting, it went back to a thread state Python may have freed. Now it stops without touching Python once exit has begun, and exit waits while it takes the GIL back. The README no longer says that every thread that logs outside a call hands its line over: only a thread that holds the GIL does, and the list of hangs that remain now includes the other case. New tests check the order of the main thread's lines after a call that raised, that the main thread does not slow down while its lines wait, and that no line is lost when fd 2 is non-blocking and full.
1 parent 87e0d97 commit 8903f4c

3 files changed

Lines changed: 224 additions & 38 deletions

File tree

‎extension/pybindings/README.md‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -241,28 +241,33 @@ look wrong there. If Python has replaced `sys.stderr`, for example with pytest's
241241
`capsys`, `contextlib.redirect_stderr` or in a Jupyter notebook, the lines go to
242242
`sys.stderr` instead, a little later. The lines a binding call logs wait until
243243
the call returns. A line logged outside any binding call, for example by the LLM
244-
runner, goes to `sys.stderr` before the next Python code runs if Python's main
245-
thread logged it. A line from another thread goes there a moment later, from a
246-
thread that the bindings start for this. Outside a call, only a thread that
247-
holds the GIL can tell where `sys.stderr` goes, so a line from a thread that
248-
does not hold it, such as one the LLM runner logs while it generates, goes to
249-
the process's stderr. The lines wait rather than go to Python at once because a
250-
backend can log while it holds a lock, and writing to Python at that moment
251-
could deadlock with another thread.
244+
runner, goes to `sys.stderr` soon after. If Python's main thread logged it, that
245+
thread writes it itself, before the lines of its next binding call. But if
246+
earlier lines of the main thread still wait for the bindings' thread, this line
247+
follows them there. A line from another thread also goes to the bindings'
248+
thread, which the bindings start for this. It writes to whatever `sys.stderr` is
249+
at that moment. Outside a call, only a thread that holds the GIL can tell where
250+
`sys.stderr` goes, so a line from a thread that does not hold it, such as one
251+
the LLM runner logs while it generates, goes to the process's stderr. The lines
252+
wait rather than go to Python at once because a backend can log while it holds a
253+
lock, and writing to Python at that moment could deadlock with another thread.
252254

253255
On Linux and macOS, a line also waits once the process's stderr is a pipe or
254256
socket that stays full for 100 ms, so a thread that holds the GIL does not hang
255257
on it while a Python thread in the same process empties it, as under
256258
`wurlitzer.pipes()`. The rest of a binding call's lines then wait for the call
257-
to return. A thread that logs outside a call does not wait for room itself. It
258-
hands the line to the bindings' thread, which writes it without the GIL. While
259-
it writes, a binding call's lines wait for the call to return. A terminal is
260-
written at once, since its reader is normally another process. The process can
261-
still hang if another writer fills the process's stderr right after the check
262-
for room, or if a Python thread in the same process reads a terminal that is the
263-
process's stderr. On macOS, a pipe that has not grown past 16 KiB takes a line
264-
only once the line fits in 512 bytes beside what the pipe holds, because macOS
265-
reports room in such a pipe that it may not have.
259+
to return. A thread that holds the GIL and logs outside a call does not wait for
260+
room itself. It hands the line to the bindings' thread, which writes it a line
261+
at a time without the GIL. While it writes, a binding call's lines wait for the
262+
call to return. A terminal is written at once, since its reader is normally
263+
another process. The process can still hang if another writer fills the
264+
process's stderr right after the check for room, if a thread that does not hold
265+
the GIL logs outside a call while a Python thread in the same process empties a
266+
full stderr and a binding call holds the GIL waiting for that thread, or if a
267+
Python thread in the same process reads a terminal that is the process's stderr.
268+
On macOS, a pipe that has not grown past 16 KiB takes a line only once the line
269+
fits in 512 bytes beside what the pipe holds, because macOS reports room in such
270+
a pipe that it may not have.
266271

267272
Up to 1 MiB of lines wait at a time; past that, about the newest 512 KiB to
268273
1 MiB are kept, with a note of how many earlier bytes were left out. A fatal

‎extension/pybindings/pybindings.cpp‎

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ struct DeferredLogs final {
181181
uint64_t batch = 1;
182182
uint64_t batches_written = 0;
183183
bool writer_running = false;
184-
// A new thread state crashes a Python that has finished exiting, so exit
185-
// waits while the writer takes the GIL with one.
184+
// Exit waits while the writer takes the GIL, since a Python that has
185+
// finished exiting crashes on a new thread state and may have freed the
186+
// writer's saved one.
186187
bool writer_entering = false;
187188
bool exiting = false;
188189
};
@@ -222,6 +223,12 @@ void write_to_fd_2(const char* data, size_t size) {
222223
if (written < 0 && errno == EINTR) {
223224
continue;
224225
}
226+
if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
227+
// fd 2 is non-blocking and full.
228+
pollfd stderr_fd{2, POLLOUT, 0};
229+
poll(&stderr_fd, 1, -1);
230+
continue;
231+
}
225232
if (written <= 0) {
226233
return;
227234
}
@@ -389,7 +396,20 @@ void write_logs_to_python(PendingLogs& logs) noexcept {
389396
if (logs.lines.empty()) {
390397
return;
391398
}
392-
// A call's lines go after the lines this thread handed to the writer thread.
399+
if (PyThread_get_thread_ident() == main_thread_id) {
400+
// The lines that the main thread's pending call has yet to write come
401+
// first, since a call that raised runs no pending call before the next.
402+
DeferredLogs& deferred = deferred_logs();
403+
std::lock_guard<std::mutex> lock(deferred.mutex);
404+
note_left_out_bytes(deferred.main_thread_logs);
405+
try {
406+
logs.lines.insert(0, deferred.main_thread_logs.lines);
407+
deferred.main_thread_logs = PendingLogs();
408+
deferred.main_thread_write_scheduled = false;
409+
} catch (const std::bad_alloc&) {
410+
}
411+
}
412+
// A call's lines go after the lines this thread handed over.
393413
if (logs.daemon || own_deferred_lines_wait()) {
394414
defer_lines(logs.lines);
395415
return;
@@ -431,10 +451,38 @@ int write_main_thread_logs(void*) noexcept {
431451
return 0;
432452
}
433453

454+
// Writes the lines to fd 2 one at a time with the GIL released, so that a line
455+
// no longer than PIPE_BUF stays whole beside other threads' lines. Returns
456+
// false, without the GIL, if Python began to exit meanwhile: Python may then
457+
// free this thread's state, so the thread must not touch Python again.
458+
bool write_lines_to_fd_2_without_gil(const std::string& lines) {
459+
PyThreadState* const state = PyEval_SaveThread();
460+
size_t begin = 0;
461+
while (begin < lines.size()) {
462+
const size_t end = lines.find('\n', begin) + 1;
463+
write_to_fd_2(lines.data() + begin, end - begin);
464+
begin = end;
465+
}
466+
DeferredLogs& deferred = deferred_logs();
467+
{
468+
std::lock_guard<std::mutex> lock(deferred.mutex);
469+
if (deferred.exiting) {
470+
return false;
471+
}
472+
deferred.writer_entering = true;
473+
}
474+
PyEval_RestoreThread(state);
475+
std::lock_guard<std::mutex> lock(deferred.mutex);
476+
deferred.writer_entering = false;
477+
deferred.writer_changed.notify_all();
478+
return true;
479+
}
480+
434481
// Writes through Python's C API only, and is not noexcept, since Python may end
435482
// this thread while it waits for the GIL during exit, and the unwinding must
436-
// neither touch a Python object nor stop at a noexcept frame.
437-
void write_lines_through_python(const PendingLogs& logs) {
483+
// neither touch a Python object nor stop at a noexcept frame. Returns false as
484+
// write_lines_to_fd_2_without_gil does.
485+
bool write_lines_through_python(const PendingLogs& logs) {
438486
PyObject* const text = PyUnicode_DecodeUTF8(
439487
logs.lines.data(), logs.lines.size(), "backslashreplace");
440488
PyObject* const stream = PySys_GetObject("stderr");
@@ -444,12 +492,11 @@ void write_lines_through_python(const PendingLogs& logs) {
444492
const bool written = result != nullptr;
445493
Py_XDECREF(result);
446494
Py_XDECREF(text);
447-
if (!written) {
448-
PyErr_Clear();
449-
PyThreadState* const state = PyEval_SaveThread();
450-
write_to_fd_2(logs.lines.data(), logs.lines.size());
451-
PyEval_RestoreThread(state);
495+
if (written) {
496+
return true;
452497
}
498+
PyErr_Clear();
499+
return write_lines_to_fd_2_without_gil(logs.lines);
453500
}
454501

455502
// Runs on a thread of its own until no line waits.
@@ -476,14 +523,16 @@ void write_deferred_logs() {
476523
deferred.writer_changed.notify_all();
477524
}
478525
note_left_out_bytes(logs);
479-
if (stderr_is_process_stderr()) {
480-
// Waits for room without the GIL or any lock of Python's, so that a full
481-
// fd 2 never holds up Python's exit.
482-
PyThreadState* const state = PyEval_SaveThread();
483-
write_to_fd_2(logs.lines.data(), logs.lines.size());
484-
PyEval_RestoreThread(state);
485-
} else {
486-
write_lines_through_python(logs);
526+
// Lines for the process's stderr wait for room in fd 2 without the GIL or
527+
// any lock of Python's, so that a full fd 2 never holds up Python's exit.
528+
const bool python_still_running = stderr_is_process_stderr()
529+
? write_lines_to_fd_2_without_gil(logs.lines)
530+
: write_lines_through_python(logs);
531+
if (!python_still_running) {
532+
std::lock_guard<std::mutex> lock(deferred.mutex);
533+
deferred.writer_running = false;
534+
deferred.writer_changed.notify_all();
535+
return;
487536
}
488537
PyGILState_Release(gil);
489538
std::lock_guard<std::mutex> lock(deferred.mutex);
@@ -508,8 +557,10 @@ void defer_lines(const std::string& lines) noexcept {
508557
// The main thread logged these lines from native code, which is inside a
509558
// sys.stderr.write only if that write runs such code. So it can write them
510559
// before its next bytecode, while a short redirect of sys.stderr still
511-
// lasts. Its earlier lines stay ahead.
560+
// lasts. While the writer thread holds earlier lines of it, these follow
561+
// them there instead, since keeping a thread's order beats the redirect.
512562
if (PyThread_get_thread_ident() == main_thread_id &&
563+
last_deferred_batch <= deferred.batches_written &&
513564
(deferred.main_thread_write_scheduled ||
514565
!sys_stderr_is_checked_fd_2())) {
515566
keep_line(deferred.main_thread_logs, lines);
@@ -539,6 +590,8 @@ bool deferred_lines_wait() {
539590
!deferred.exiting;
540591
}
541592

593+
// Whether lines this thread handed over still wait, which its later lines must
594+
// not pass.
542595
bool own_deferred_lines_wait() {
543596
DeferredLogs& deferred = deferred_logs();
544597
std::lock_guard<std::mutex> lock(deferred.mutex);

‎extension/pybindings/test/test_pybindings.py‎

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,28 @@ def log_twice():
707707
print("in order:", 0 <= first < text.find("Program identifier"))
708708
sys.exit()
709709
710+
if sys.argv[1] == "replaced, a call after a failed load":
711+
in_order = True
712+
# The first round comes out in order even without the wait, so it repeats.
713+
for _ in range(5):
714+
captured = io.StringIO()
715+
sys.stderr = captured
716+
try:
717+
TextLLMRunner(missing_program, sys.argv[2])
718+
except RuntimeError:
719+
# A call that raised runs no pending call before the next call, yet
720+
# this call's line must come after the runner's lines.
721+
try:
722+
portable_lib._load_program_from_buffer(b"x" * 128)
723+
except RuntimeError:
724+
pass
725+
sys.stderr = sys.__stderr__
726+
text = captured.getvalue()
727+
first = text.find("[llm_runner_helper.cpp:")
728+
in_order = in_order and 0 <= first < text.find("Program identifier")
729+
print("in order:", in_order)
730+
sys.exit()
731+
710732
with (
711733
contextlib.redirect_stderr(captured)
712734
if sys.argv[1] == "replaced after import"
@@ -729,6 +751,7 @@ def log_twice():
729751
"replaced before import",
730752
"replaced after import",
731753
"replaced, another thread",
754+
"replaced, a call after a failed load",
732755
"process stderr",
733756
):
734757
completed = subprocess.run(
@@ -744,8 +767,8 @@ def log_twice():
744767
self.skipTest("the LLM runner bindings are not built")
745768
if mode == "process stderr":
746769
self.assertIn("[llm_runner_helper.cpp:", completed.stderr)
747-
elif mode == "replaced, another thread":
748-
self.assertIn("in order: True", completed.stdout)
770+
elif mode.startswith("replaced, a"):
771+
self.assertIn("in order: True", completed.stdout, mode)
749772
else:
750773
self.assertIn("lines through Python:", completed.stdout, mode)
751774
self.assertNotIn("lines through Python: 0", completed.stdout, mode)
@@ -1076,6 +1099,111 @@ def build_runners():
10761099
if "no LLM runner" not in completed.stdout:
10771100
self.assertIn("done", completed.stdout, f"{mode}, {kind}")
10781101

1102+
def test_lines_waiting_for_a_full_stderr(self):
1103+
if self.kernel_mode != "portable":
1104+
self.skipTest("the script below uses the portable bindings")
1105+
if sys.platform == "win32":
1106+
self.skipTest("uses a POSIX pipe as fd 2")
1107+
1108+
script = """
1109+
import io
1110+
import os
1111+
import sys
1112+
import threading
1113+
import time
1114+
1115+
from executorch.extension.pybindings import portable_lib
1116+
1117+
try:
1118+
from executorch.extension.llm.runner import TextLLMRunner
1119+
except (ImportError, RuntimeError):
1120+
# The package raises RuntimeError when its extension is not built.
1121+
print("no LLM runner")
1122+
sys.exit()
1123+
missing_program = os.path.join(os.path.dirname(sys.argv[2]), "missing.pte")
1124+
1125+
1126+
def spin(seconds):
1127+
rounds = 0
1128+
end = time.perf_counter() + seconds
1129+
while time.perf_counter() < end:
1130+
for _ in range(100):
1131+
rounds += 1
1132+
return rounds
1133+
1134+
1135+
normal_speed = spin(0.5)
1136+
read_end, write_end = os.pipe()
1137+
os.set_blocking(write_end, False)
1138+
for size in (4096, 512, 64, 1):
1139+
try:
1140+
while True:
1141+
os.write(write_end, b"x" * size)
1142+
except BlockingIOError:
1143+
pass
1144+
saved_stderr = os.dup(2)
1145+
os.dup2(write_end, 2)
1146+
if sys.argv[1] == "blocking":
1147+
os.set_blocking(2, True)
1148+
drained = bytearray()
1149+
1150+
1151+
def drain():
1152+
# Starts once the lines below wait, and needs the GIL after every read.
1153+
time.sleep(2)
1154+
os.set_blocking(read_end, False)
1155+
end = time.monotonic() + 3
1156+
while time.monotonic() < end:
1157+
try:
1158+
drained.extend(os.read(read_end, 65536))
1159+
except BlockingIOError:
1160+
time.sleep(0.01)
1161+
1162+
1163+
drainer = threading.Thread(target=drain)
1164+
drainer.start()
1165+
try:
1166+
# fd 2 is full, so these lines go to a thread of the bindings, which waits
1167+
# to write them.
1168+
try:
1169+
TextLLMRunner(missing_program, sys.argv[2])
1170+
except RuntimeError:
1171+
pass
1172+
if sys.argv[1] == "blocking":
1173+
# The main thread's later lines must wait behind those without making
1174+
# it spin.
1175+
time.sleep(0.3)
1176+
sys.stderr = io.StringIO()
1177+
try:
1178+
portable_lib._load_program_from_buffer(b"x" * 128)
1179+
except RuntimeError:
1180+
pass
1181+
print("speed:", round(spin(0.5) / normal_speed, 3))
1182+
sys.stderr = sys.__stderr__
1183+
drainer.join()
1184+
finally:
1185+
os.dup2(saved_stderr, 2)
1186+
print("runner lines:", drained.count(b"[llm_runner_helper.cpp:"))
1187+
"""
1188+
with tempfile.TemporaryDirectory() as directory:
1189+
tokenizer = write_llm_tokenizer(directory)
1190+
for mode in ("blocking", "non-blocking"):
1191+
completed = subprocess.run(
1192+
[sys.executable, "-c", script, mode, tokenizer],
1193+
capture_output=True,
1194+
text=True,
1195+
timeout=120,
1196+
)
1197+
self.assertEqual(
1198+
completed.returncode, 0, f"{mode}: {completed.stderr[-2000:]}"
1199+
)
1200+
if "no LLM runner" in completed.stdout:
1201+
self.skipTest("the LLM runner bindings are not built")
1202+
self.assertNotIn("runner lines: 0", completed.stdout, mode)
1203+
if mode == "blocking":
1204+
speed = float(completed.stdout.split("speed:")[1].split()[0])
1205+
self.assertGreater(speed, 0.05, completed.stdout)
1206+
10791207
def test_deferred_runtime_logs_do_not_nest_in_a_stderr_write(self):
10801208
if self.kernel_mode != "portable":
10811209
self.skipTest("the script below uses the portable bindings")

0 commit comments

Comments
 (0)