diff --git a/benchmarks/typedpython/results/2026-10-17-eval-breaker-ab.json b/benchmarks/typedpython/results/2026-10-17-eval-breaker-ab.json new file mode 100644 index 000000000..8c46e092c --- /dev/null +++ b/benchmarks/typedpython/results/2026-10-17-eval-breaker-ab.json @@ -0,0 +1,308 @@ +{ + "fannkuch-redux": { + "size": "10", + "pairs": [ + { + "before_s": 0.617363250348717, + "after_s": 0.7355743343941867, + "cpython_s": 6.021780250128359, + "after_over_before": 1.191477357906708, + "speedup_before": 9.75403094811193, + "speedup_after": 8.186501307291872, + "load": [ + 3.6943359375, + 3.02001953125, + 2.7109375 + ] + }, + { + "after_s": 0.27955787535756826, + "before_s": 0.2578067500144243, + "cpython_s": 6.004611459095031, + "after_over_before": 1.0843698830303201, + "speedup_before": 23.291133605924095, + "speedup_after": 21.47895655386148, + "load": [ + 3.513671875, + 3.00244140625, + 2.7080078125 + ] + }, + { + "before_s": 0.2493240418843925, + "after_s": 0.2699953746050596, + "cpython_s": 5.923735792282969, + "after_over_before": 1.0829095042918166, + "speedup_before": 23.759184022171873, + "speedup_after": 21.940138052172244, + "load": [ + 3.31201171875, + 2.96875, + 2.69775390625 + ] + }, + { + "after_s": 0.2705523748882115, + "before_s": 0.2492111660540104, + "cpython_s": 5.963486583903432, + "after_over_before": 1.0856350426512427, + "speedup_before": 23.929451791140824, + "speedup_after": 22.04189331683916, + "load": [ + 3.12646484375, + 2.93603515625, + 2.6875 + ] + }, + { + "before_s": 0.2592446249909699, + "after_s": 0.2709223749116063, + "cpython_s": 5.9388052499853075, + "after_over_before": 1.045045292341329, + "speedup_before": 22.908113331924124, + "speedup_after": 21.9206894665786, + "load": [ + 2.9560546875, + 2.90380859375, + 2.67724609375 + ] + }, + { + "after_s": 0.27158462489023805, + "before_s": 0.25109495781362057, + "cpython_s": 5.959664166904986, + "after_over_before": 1.0816012685201997, + "speedup_before": 23.73470267502801, + "speedup_after": 21.944041085954726, + "load": [ + 3.12255859375, + 2.93994140625, + 2.69189453125 + ] + }, + { + "before_s": 0.2600955842062831, + "after_s": 0.2711727498099208, + "cpython_s": 5.943080165889114, + "after_over_before": 1.0425888260942267, + "speedup_before": 22.84960040373322, + "speedup_after": 21.916214553471654, + "load": [ + 2.9521484375, + 2.90771484375, + 2.681640625 + ] + }, + { + "after_s": 0.2703690002672374, + "before_s": 0.2523987921886146, + "cpython_s": 5.931899290997535, + "after_over_before": 1.0711976785736514, + "speedup_before": 23.50209063823371, + "speedup_after": 21.940012668369313, + "load": [ + 2.79541015625, + 2.8759765625, + 2.67138671875 + ] + }, + { + "before_s": 0.25141441682353616, + "after_s": 0.2719767093658447, + "cpython_s": 5.947182500269264, + "after_over_before": 1.0817864496479568, + "speedup_before": 23.654898455737715, + "speedup_after": 21.86651391634243, + "load": [ + 2.8134765625, + 2.87939453125, + 2.67431640625 + ] + }, + { + "after_s": 0.27185420878231525, + "before_s": 0.24923187540844083, + "cpython_s": 5.944646291900426, + "after_over_before": 1.0907682186991572, + "speedup_before": 23.851869999205956, + "speedup_after": 21.86703791906546, + "load": [ + 4.029296875, + 3.13037109375, + 2.763671875 + ] + }, + { + "before_s": 0.2507732920348644, + "after_s": 0.2709160838276148, + "cpython_s": 5.93707116600126, + "after_over_before": 1.080322715506522, + "speedup_before": 23.67505374207012, + "speedup_after": 21.91479768244047, + "load": [ + 3.86669921875, + 3.111328125, + 2.7587890625 + ] + } + ], + "median_after_over_before": 1.0817864496479568, + "min_after_over_before": 1.0425888260942267, + "max_after_over_before": 1.191477357906708, + "median_speedup_before": 23.654898455737715, + "median_speedup_after": 21.916214553471654 + }, + "nbody": { + "size": "1000000", + "pairs": [ + { + "before_s": 0.5934657920151949, + "after_s": 0.5233573750592768, + "cpython_s": 4.006437666714191, + "after_over_before": 0.8818661194980502, + "speedup_before": 6.750915925768493, + "speedup_after": 7.655261696198342, + "load": [ + 3.95703125, + 3.142578125, + 2.771484375 + ] + }, + { + "after_s": 0.13168670795857906, + "before_s": 0.13310000021010637, + "cpython_s": 4.033577458932996, + "after_over_before": 0.9893817261510418, + "speedup_before": 30.3048644069553, + "speedup_after": 30.63010323108482, + "load": [ + 3.73583984375, + 3.12109375, + 2.767578125 + ] + }, + { + "before_s": 0.12832820834591985, + "after_s": 0.1279506660066545, + "cpython_s": 4.010861457791179, + "after_over_before": 0.9970579941531822, + "speedup_before": 31.25471406083652, + "speedup_after": 31.346936932571975, + "load": [ + 3.73583984375, + 3.12109375, + 2.767578125 + ] + }, + { + "after_s": 0.12794629205018282, + "before_s": 0.12665566708892584, + "cpython_s": 4.075236166827381, + "after_over_before": 1.0101900293206054, + "speedup_before": 32.175711205769645, + "speedup_after": 31.851147083097963, + "load": [ + 3.6767578125, + 3.11865234375, + 2.7685546875 + ] + }, + { + "before_s": 0.13051274977624416, + "after_s": 0.1298941671848297, + "cpython_s": 4.128146333154291, + "after_over_before": 0.9952603665735725, + "speedup_before": 31.630214980771886, + "speedup_after": 31.780844533844594, + "load": [ + 3.46240234375, + 3.0830078125, + 2.7578125 + ] + }, + { + "after_s": 0.1337217092514038, + "before_s": 0.1343525000847876, + "cpython_s": 4.176525415852666, + "after_over_before": 0.9953049564914258, + "speedup_before": 31.086324506182844, + "speedup_after": 31.23296463404143, + "load": [ + 3.42529296875, + 3.08154296875, + 2.7587890625 + ] + }, + { + "before_s": 0.13416387513279915, + "after_s": 0.1304794168099761, + "cpython_s": 4.760525666177273, + "after_over_before": 0.9725376274412463, + "speedup_before": 35.48291715236439, + "speedup_after": 36.48487847788492, + "load": [ + 3.31103515625, + 3.0634765625, + 2.75390625 + ] + }, + { + "after_s": 0.14065329218283296, + "before_s": 0.13487516716122627, + "cpython_s": 4.0361085827462375, + "after_over_before": 1.0428405402063352, + "speedup_before": 29.9247716810729, + "speedup_after": 28.695443384999226, + "load": [ + 3.44580078125, + 3.09521484375, + 2.7666015625 + ] + }, + { + "before_s": 0.12785816565155983, + "after_s": 0.1291002919897437, + "cpython_s": 4.002894583158195, + "after_over_before": 1.0097148768860718, + "speedup_before": 31.307304955922156, + "speedup_after": 31.006084660724103, + "load": [ + 3.32958984375, + 3.07666015625, + 2.76171875 + ] + }, + { + "after_s": 0.12757845874875784, + "before_s": 0.12731604231521487, + "cpython_s": 4.101447000168264, + "after_over_before": 1.0020611419328702, + "speedup_before": 32.214691295647675, + "speedup_after": 32.14842882092897, + "load": [ + 3.142578125, + 3.0419921875, + 2.7509765625 + ] + }, + { + "before_s": 0.13188975024968386, + "after_s": 0.12789770774543285, + "cpython_s": 4.141773249953985, + "after_over_before": 0.9697319731313953, + "speedup_before": 31.403298907709573, + "speedup_after": 32.383483042540185, + "load": [ + 3.142578125, + 3.0419921875, + 2.7509765625 + ] + } + ], + "median_after_over_before": 0.9953049564914258, + "min_after_over_before": 0.8818661194980502, + "max_after_over_before": 1.0428405402063352, + "median_speedup_before": 31.307304955922156, + "median_speedup_after": 31.346936932571975 + } +} diff --git a/benchmarks/typedpython/results/2026-10-17-eval-breaker-nopoll-experiment.json b/benchmarks/typedpython/results/2026-10-17-eval-breaker-nopoll-experiment.json new file mode 100644 index 000000000..957de61fb --- /dev/null +++ b/benchmarks/typedpython/results/2026-10-17-eval-breaker-nopoll-experiment.json @@ -0,0 +1,155 @@ +{ + "fannkuch-redux": { + "size": "10", + "pairs": [ + { + "before_s": 0.5521271657198668, + "after_s": 0.6070619588717818, + "cpython_s": 5.987047249916941, + "after_over_before": 1.099496631505698, + "speedup_before": 10.84360202076091, + "speedup_after": 9.86233309865735, + "load": [ + 2.44775390625, + 2.82666015625, + 2.69140625 + ] + }, + { + "after_s": 0.2503165830858052, + "before_s": 0.250257957726717, + "cpython_s": 6.000943332910538, + "after_over_before": 1.0002342597199334, + "speedup_before": 23.979031026312455, + "speedup_after": 23.97341502082383, + "load": [ + 2.41162109375, + 2.8125, + 2.68701171875 + ] + }, + { + "before_s": 0.26244004210457206, + "after_s": 0.2621465837582946, + "cpython_s": 5.951287917327136, + "after_over_before": 0.9988818080353739, + "speedup_before": 22.67675263882095, + "speedup_after": 22.702137987097956, + "load": [ + 2.50146484375, + 2.818359375, + 2.68994140625 + ] + }, + { + "after_s": 0.25116583285853267, + "before_s": 0.2525885826908052, + "cpython_s": 5.937470917124301, + "after_over_before": 0.9943673232688663, + "speedup_before": 23.506489699070784, + "speedup_after": 23.639644172734826, + "load": [ + 2.380859375, + 2.7880859375, + 2.6796875 + ] + }, + { + "before_s": 0.2509259171783924, + "after_s": 0.2509960001334548, + "cpython_s": 5.959116999991238, + "after_over_before": 1.000279297395225, + "speedup_before": 23.748511381367926, + "speedup_after": 23.741880336032327, + "load": [ + 2.27001953125, + 2.75830078125, + 2.66943359375 + ] + }, + { + "after_s": 0.24977354193106294, + "before_s": 0.24995683319866657, + "cpython_s": 5.941518500447273, + "after_over_before": 0.9992667083141593, + "speedup_before": 23.770178332051973, + "speedup_after": 23.78762159719512, + "load": [ + 2.248046875, + 2.74560546875, + 2.6650390625 + ] + }, + { + "before_s": 0.24905870808288455, + "after_s": 0.24908804101869464, + "cpython_s": 5.942821917124093, + "after_over_before": 1.0001177751865649, + "speedup_before": 23.861128819259648, + "speedup_after": 23.85831890129992, + "load": [ + 2.203125, + 2.72021484375, + 2.65673828125 + ] + }, + { + "after_s": 0.2507365830242634, + "before_s": 0.24994562473148108, + "cpython_s": 5.947943000122905, + "after_over_before": 1.0031645214579452, + "speedup_before": 23.796947862211372, + "speedup_after": 23.72187946561963, + "load": [ + 2.1064453125, + 2.69140625, + 2.64697265625 + ] + }, + { + "before_s": 0.2490944997407496, + "after_s": 0.24957312503829598, + "cpython_s": 5.9209016668610275, + "after_over_before": 1.0019214607229165, + "speedup_before": 23.769700547476287, + "speedup_after": 23.7241155911819, + "load": [ + 2.09765625, + 2.6796875, + 2.64306640625 + ] + }, + { + "after_s": 0.24998520780354738, + "before_s": 0.2506584580987692, + "cpython_s": 5.945883291773498, + "after_over_before": 0.9973140731003918, + "speedup_before": 23.721055881667432, + "speedup_after": 23.784940493143544, + "load": [ + 2.001953125, + 2.64013671875, + 2.62939453125 + ] + }, + { + "before_s": 0.2553401249460876, + "after_s": 0.25701129203662276, + "cpython_s": 6.079530125018209, + "after_over_before": 1.0065448667376036, + "speedup_before": 23.809536892416883, + "speedup_after": 23.654719902936826, + "load": [ + 1.92138671875, + 2.61279296875, + 2.61962890625 + ] + } + ], + "median_after_over_before": 1.0002342597199334, + "min_after_over_before": 0.9943673232688663, + "max_after_over_before": 1.099496631505698, + "median_speedup_before": 23.769700547476287, + "median_speedup_after": 23.7241155911819 + } +} diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md index c4619895d..d2089d25e 100644 --- a/docs/design/typedpython.md +++ b/docs/design/typedpython.md @@ -309,6 +309,11 @@ PyPy·GraalVM 과 같은 방식이다. N-11 클래스의 객체를 PyObject 대 **1단계 구현 (#141):** 루프와 함수 진입에서 폴링하고, 일정 횟수마다 모듈의 `lambda: None` 을 불러 CPython 자기 eval breaker 처리를 그대로 돌린다. 배열 copy-in·전역 스냅샷을 쥔 함수가 실행 중이면 미룬다. 그 함수 안에서의 GIL 넘기기는 2단계(#154)이고, 방식은 결정 대기(#152). + **비용 (2026-10-17, 조용한 창, develop 대비 교대 11쌍):** fannkuch +8.2% (중앙값 1.082, 범위 1.043~1.191), + nbody 0.995 (배열 스냅샷 함수는 폴링하지 않음). 루프 폴링을 뺀 실험판은 fannkuch 1.000 이므로 남은 비용은 + 전부 짧은 안쪽 루프의 매 반복 카운트다운이다. CPython 도 모든 back-edge 에서 eval breaker 를 검사하므로 같은 + 종류의 비용이고, 시그널·스레드 굶김과 3.15t 의 GC stop-the-world 차단을 고치는 대가로 받아들였다(리더 결정). + range 루프의 묶음 폴링은 후속 이슈다. 원자료: `benchmarks/typedpython/results/2026-10-17-eval-breaker-*.json`. 3. **이미 있는 차이: 추적·모니터링.** 컴파일된 함수는 `sys.settrace`·`sys.setprofile`·`sys.monitoring` 이벤트를 내지 않는다. 진입에서 추적이 켜져 있으면 인터프리터로 실행하는 방법(deopt 경로 재사용)을 제안한다. 이것도 별도 이슈다. diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index b5e5f0829..602f86d26 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -459,6 +459,7 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No w(f" PyObject *descrs[{mg.n_slots + 1}]; /* strong: the member descriptor of each slot */") w(f" tp_class_rt cls_rt[{len(module.classes) + 1}]; /* version-tag / global guard state */") w(" int watch_id1; /* dict watcher id + 1 on the module dict; 0 = none */") + w(" PyObject *breaker; /* `lambda: None`; calling it runs the eval breaker (#141) */") w("} tp_state;") w("") w("static const char *const tp_name_strings[] = {") @@ -1337,9 +1338,23 @@ def st_If(self, s: ir.If) -> None: self.depth -= 1 self.emit("}") + @property + def snapshot(self) -> bool: + """Holds an array copy-in or an entry-globals snapshot, so must not run user code (#141).""" + return bool(self.f.entry_globals) or any(isinstance(p, ir.ArrayParam) for p in self.f.params) + + def poll(self) -> None: + """At the top of each loop iteration (#141): signals, pending calls, GIL hand-off.""" + if not self.snapshot: + # A function-local countdown (kept in a register), not the module counter: a load and + # store of a global on every iteration of a short inner loop cost fannkuch ~38%. + self.emit("if (tp_pc_tick(&tp_pc) && tp_poll_slow(tp_st->breaker) != 0) " + "{ tp_rc = -1; goto tp_exit; }") + def st_While(self, s: ir.While) -> None: self.emit("for (;;) {") self.depth += 1 + self.poll() c = self._cond(s.cond) self.emit(f"if (!({c})) break;") self.stmts(s.body) @@ -1376,6 +1391,7 @@ def st_ForRange(self, s: ir.ForRange) -> None: self.depth += 1 self.emit(f"for ({tk} = 0; {tk} < {tn}; {tk}++) {{") self.depth += 1 + self.poll() self.emit(f"{var} = (int64_t)((uint64_t){ts} {sign} {tk} * {mag});") self.stmts(s.body) self.depth -= 1 @@ -1425,6 +1441,7 @@ def generate(self) -> str: out = [_impl_signature(f), "{", " int tp_rc = 0;", " int tp_s = 0;", + " int tp_pc = TP_POLL_INTERVAL; /* loop poll countdown (#141) */", " tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module);", " PyObject *tp_dict = PyModule_GetDict(tp_module); /* borrowed: owned by the module */"] params = {p.name for p in f.params} | {g.name for g in f.entry_globals} @@ -1440,7 +1457,7 @@ def generate(self) -> str: init = "NULL" if ct == "PyObject *" else "{0}" if ct in _CT_ARRAY_REV else "0" sep = "" if ct.endswith("*") else " " out.append(f" {ct}{sep}{n} = {init};") - out.append(" (void)tp_st; (void)tp_dict; (void)tp_s;" + out.append(" (void)tp_st; (void)tp_dict; (void)tp_s; (void)tp_pc;" + "".join(f" (void){_ident('l', n)};" for n in self.types if n not in params) + "".join(f" (void){_ident('l', n)};" for n in self.local_arrays)) # Depth guard (issue #57): nothing is owned yet, so a refusal returns directly and counts @@ -1450,10 +1467,17 @@ def generate(self) -> str: k = self._class(p.cls) out.append(f" if ({_param_cls_fail(_ident('p', p.name), k, p.optional)}) return 1;") out.append(" if (tp_enter_call() != 0) return -1;") + # Eval breaker (#141): a snapshot-holding function never polls and defers others' polls. + if self.snapshot: + out.append(" tp_snapshot_depth++;") + else: + out.append(" if (tp_poll(tp_st->breaker) != 0) { tp_rc = -1; goto tp_exit; }") out += prologue out += self.lines out.append("tp_exit:") out += self._own_release_all() + if self.snapshot: + out.append(" tp_snapshot_depth--;") out.append(" tp_leave_call();") out.append(" return tp_rc;") out.append("}") @@ -1631,6 +1655,7 @@ def _state_functions(mg: _ModGen) -> str: " tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module);", " size_t i;", " if (tp_st == NULL) return 0;", + " Py_VISIT(tp_st->breaker);", " for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) Py_VISIT(tp_st->names[i]);", " for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) Py_VISIT(tp_st->kwnames[i]);", " for (i = 0; i < sizeof(tp_st->classes) / sizeof(tp_st->classes[0]); i++) Py_VISIT(tp_st->classes[i]);", @@ -1645,6 +1670,7 @@ def _state_functions(mg: _ModGen) -> str: " size_t i;", " if (tp_st == NULL) return 0;", " if (tp_st->watch_id1 > 0) tp_globals_unwatch(PyModule_GetDict(tp_module), &tp_st->watch_id1);", + " tp_release(&tp_st->breaker);", " for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) tp_release(&tp_st->names[i]);", " for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) tp_release(&tp_st->kwnames[i]);", " for (i = 0; i < sizeof(tp_st->cls_ok) / sizeof(tp_st->cls_ok[0]); i++) tp_st->cls_ok[i] = 0;", @@ -1727,6 +1753,11 @@ def _exec_function(mg: _ModGen) -> str: " tp_release(&tp_b);", " if (tp_k < 0) goto tp_done;", " }", + " tp_code = Py_CompileString(\"lambda: None\", \"\", Py_eval_input);", + " if (tp_code == NULL) goto tp_done;", + " tp_st->breaker = PyEval_EvalCode(tp_code, tp_dict, tp_dict);", + " tp_release(&tp_code);", + " if (tp_st->breaker == NULL) goto tp_done;", " tp_code = Py_CompileString(tp_source, tp_source_path, Py_file_input);", " if (tp_code == NULL) goto tp_done;", " tp_res = PyEval_EvalCode(tp_code, tp_dict, tp_dict);", diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md index 8ddb5ed06..ddfb4651c 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -98,6 +98,17 @@ C-stack guard: a raised recursion limit can never let compiled C overflow the st per module (the header is `static`): frames of a *different* compiled module that sit between two frames of this one are not counted. +## Eval breaker (top of every loop iteration and impl entry; issue #141) + int tp_poll(PyObject *breaker); /* 0 continue; -1 exception set (a signal handler raised) */ + int tp_poll_slow(PyObject *breaker); /* every TP_POLL_INTERVAL polls: call the breaker */ + int tp_pc_tick(int *pc); /* loop countdown: 1 (and reset) when the slow path is due */ + Py_ssize_t tp_snapshot_depth; /* ++ at entry, -- at tp_exit of a snapshot-holding impl */ +`breaker` is the module state's `lambda: None`. Calling it runs CPython's own eval-breaker handling +in its RESUME: signals, pending calls, forced GIL switching to a waiting thread, async exceptions and +scheduled collections, exactly as between two bytecodes. A function that holds an array copy-in +(ir.ArrayParam) or an entry-globals snapshot never polls, and while one is active the slow path does +nothing: the event stays pending and is handled after it returns. + ## Fixed-layout classes (ir.ClassDecl / FieldGet / FieldSet / New / IsExact / CheckExact; SPEC N-11) Guard state, one per ClassDecl in the module state (zeroed = not compiled): diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h index e0b2f3193..17ea6b373 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h @@ -1455,4 +1455,72 @@ static inline void tp_leave_call(void) } } +/* ------------------------------------------------------------------------------------------ + * Eval breaker (issue #141). The interpreter checks between bytecodes for signals, pending calls, + * GIL drop requests, async exceptions and scheduled collections; compiled code has no bytecodes. + * So at the top of every loop iteration and at every impl entry `tp_poll` counts down a module + * counter (one decrement under the GIL), and every TP_POLL_INTERVAL polls `tp_poll_slow` calls a + * Python no-op (`lambda: None`, made by the module's exec slot). Its RESUME runs CPython's own + * eval-breaker handling, so everything the interpreter would do there happens, the same way + * (forced GIL switching to a waiting thread, KeyboardInterrupt, gc). Public API only. + * + * Signal handlers, pending calls and other threads are user code that may change lists and + * globals. A function holding an array copy-in or an entry-globals snapshot runs no user code by + * contract (ir.ArrayParam, ir.Function.entry_globals), so such a function does not poll, and while + * one is active (`tp_snapshot_depth > 0`, counted at its entry and exit) the slow path does nothing: + * the event stays pending and is handled at the next poll or bytecode after it returns, which is the + * CPython execution in which it arrived late. + * ------------------------------------------------------------------------------------------ */ +#ifndef TP_POLL_INTERVAL +#define TP_POLL_INTERVAL 4096 +#endif + +static int tp_poll_countdown = TP_POLL_INTERVAL; +static Py_ssize_t tp_snapshot_depth = 0; + +/* Kept out of line and marked cold: inlined into every loop body it bloated short inner loops + * (fannkuch paid ~11% with it inline). */ +#if defined(__GNUC__) || defined(__clang__) +#define TP_COLD __attribute__((noinline, cold, unused)) +#define TP_UNLIKELY(x) __builtin_expect(!!(x), 0) +#else +#define TP_COLD +#define TP_UNLIKELY(x) (x) +#endif + +static TP_COLD int tp_poll_slow(PyObject *breaker) +{ + PyObject *r; + tp_poll_countdown = TP_POLL_INTERVAL; + if (tp_snapshot_depth > 0 || breaker == NULL) { + return 0; + } + r = PyObject_CallNoArgs(breaker); + if (r == NULL) { + return -1; + } + Py_DECREF(r); + return 0; +} + +/* A loop's poll countdown (a local of the impl, kept in a register): 1 when the slow path is due, + * which also resets the countdown. Inline, with the rare case marked unlikely. */ +static inline int tp_pc_tick(int *pc) +{ + if (TP_UNLIKELY(--*pc == 0)) { + *pc = TP_POLL_INTERVAL; + return 1; + } + return 0; +} + +/* 0 = continue, -1 = exception set (e.g. KeyboardInterrupt from a signal handler). */ +static inline int tp_poll(PyObject *breaker) +{ + if (!TP_UNLIKELY(--tp_poll_countdown <= 0)) { + return 0; + } + return tp_poll_slow(breaker); +} + #endif /* TP_RUNTIME_H */ diff --git a/python-multiplatform-ksp/src/test/python/test_eval_breaker.py b/python-multiplatform-ksp/src/test/python/test_eval_breaker.py new file mode 100644 index 000000000..bd08f6bb4 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_eval_breaker.py @@ -0,0 +1,156 @@ +"""Compiled code checks the eval breaker (#141, SPEC N-8): during a long compiled loop or recursion, +signals reach Python (KeyboardInterrupt), and other threads get the GIL. + +A Python signal handler, a pending call and another thread are user code that can change lists and +globals. A function that holds an array copy-in (ArrayParam) or an entry-globals snapshot runs no +user code by contract, so while such a function is active the poll is deferred: the result is then +the CPython execution in which the signal arrived after the call. + +Each scenario runs in a child interpreter, so a broken build cannot take pytest down and signal +handlers stay out of the test process. Timing thresholds are generous; they separate "polled" +(tens of ms) from "never polled" (the whole loop, seconds). +""" +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from typedpython import pipeline + +SOURCE = """\ +# typedpython: compiled + + +def spin(n: int) -> float: + acc: float = 0.0 + for k in range(n): + acc += k * 0.5 + return acc + + +def fib(n: int) -> int: + if n < 2: + return n + return fib(n - 1) + fib(n - 2) + + +def total(xs: list[float], rounds: int) -> float: + s: float = 0.0 + for r in range(rounds): + for i in range(len(xs)): + s += xs[i] + return s +""" + +LOADER = """\ +import importlib.machinery, importlib.util, json, signal, sys, threading, time +path = sys.argv[1] +loader = importlib.machinery.ExtensionFileLoader("loops", path) +spec = importlib.util.spec_from_file_location("loops", path, loader=loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) +""" + + +@pytest.fixture(scope="module") +def extension(tmp_path_factory): + d = tmp_path_factory.mktemp("eb") + path = d / "loops.py" + path.write_text(SOURCE) + result = pipeline.compile_module(path, d / "out") + assert result.extension is not None, result.skipped + names = {f.name for f in result.module.functions} + assert names >= {"spin", "fib", "total"}, result.skipped + return result.extension + + +def run_child(extension: Path, body: str) -> dict: + script = LOADER + textwrap.dedent(body) + proc = subprocess.run([sys.executable, "-c", script, str(extension)], + capture_output=True, text=True, timeout=120) + assert proc.returncode == 0, f"child failed ({proc.returncode}):\n{proc.stdout}\n{proc.stderr}" + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_keyboard_interrupt_reaches_a_compiled_loop(extension): + r = run_child(extension, """ + threading.Timer(0.05, lambda: __import__("_thread").interrupt_main()).start() + t0 = time.perf_counter() + try: + m.spin(4_000_000_000) # seconds of work if never interrupted + out = "completed" + except KeyboardInterrupt: + out = "interrupted" + print(json.dumps({"out": out, "elapsed": time.perf_counter() - t0})) + """) + assert r["out"] == "interrupted" + assert r["elapsed"] < 1.0, r + + +def test_keyboard_interrupt_reaches_compiled_recursion(extension): + r = run_child(extension, """ + threading.Timer(0.05, lambda: __import__("_thread").interrupt_main()).start() + t0 = time.perf_counter() + try: + m.fib(45) # many seconds compiled; no loop, only calls + out = "completed" + except KeyboardInterrupt: + out = "interrupted" + print(json.dumps({"out": out, "elapsed": time.perf_counter() - t0})) + """) + assert r["out"] == "interrupted" + assert r["elapsed"] < 1.0, r + + +def test_another_thread_runs_while_a_compiled_loop_runs(extension): + r = run_child(extension, """ + stamps = [] + stop = False + def worker(): + n = 0 + while not stop: + n += 1 + if n % 1000 == 0: + stamps.append(time.perf_counter()) + t = threading.Thread(target=worker) + t.start() + time.sleep(0.05) + t0 = time.perf_counter() + m.spin(1_500_000_000) + t1 = time.perf_counter() + stop = True + t.join() + inside = [s for s in stamps if t0 + 0.1 < s < t1 - 0.1] + print(json.dumps({"inside": len(inside), "call_s": t1 - t0})) + """) + assert r["call_s"] > 0.3, f"loop too short to judge: {r}" + assert r["inside"] > 0, f"the worker thread never ran during the compiled call: {r}" + + +def test_a_handler_is_deferred_while_an_array_copy_is_live(extension): + # total() reads its list through a copy-in. A handler that writes the list mid-call would be + # invisible to the copy, a result no CPython execution can give. So the handler must run after + # the call (the execution where the signal arrived late), or the result must include its write. + r = run_child(extension, """ + xs = [1.0] * 100_000 + seen = {} + def handler(signum, frame): + seen["t"] = time.perf_counter() + xs[0] = 1.0e6 + signal.signal(signal.SIGUSR1, handler) + import os + threading.Timer(0.05, lambda: os.kill(os.getpid(), signal.SIGUSR1)).start() + t0 = time.perf_counter() + s = m.total(xs, 20_000) + t1 = time.perf_counter() + time.sleep(0.01) # let a deferred handler run + print(json.dumps({"s": s, "plain": 100_000 * 20_000 * 1.0, "t0": t0, "t1": t1, + "handler": seen.get("t")})) + """) + assert r["handler"] is not None, "the handler never ran" + assert r["t1"] - r["t0"] > 0.3, f"call too short to judge: {r}" + if r["s"] == r["plain"]: + assert r["handler"] >= r["t1"], f"handler ran inside the call but its write is not in the result: {r}"