Skip to content

Commit f5bd67e

Browse files
authored
Merge pull request #28 from benoitc/feature/schedule-inline
Add erlang.schedule_inline() for inline continuations
2 parents 8908b07 + de5f729 commit f5bd67e

File tree

8 files changed

+1104
-15
lines changed

8 files changed

+1104
-15
lines changed

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
# Changelog
22

3-
## 2.2.0 (2026-03-13)
3+
## 2.2.0 (unreleased)
44

55
### Added
66

7+
- **Inline Continuation API** - High-performance scheduling without Erlang messaging
8+
- `erlang.schedule_inline(module, func, args, kwargs)` - Chain Python calls via `enif_schedule_nif()`
9+
- ~3x faster than `schedule_py` for tight loops (bypasses gen_server messaging)
10+
- Captures caller's globals/locals for correct namespace resolution with subinterpreters
11+
- `InlineScheduleMarker` type returned, must be returned from handler
12+
- See [Scheduling API docs](docs/asyncio.md#explicit-scheduling-api)
13+
14+
- **Inline Continuation Benchmark** - Performance comparison
15+
- `bench_schedule_inline` in `examples/benchmark.erl`
16+
- Compares `schedule_inline` vs `schedule_py` throughput
17+
718
- **Process-Bound Python Environments** - Each Erlang process gets an isolated Python namespace
819
- `py:get_local_env/1` - Get or create a process-local Python environment
920
- Variables defined via `py:exec()` persist across calls within the same Erlang process

c_src/py_callback.c

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,6 +1420,167 @@ static PyObject *py_schedule_py(PyObject *self, PyObject *args, PyObject *kwargs
14201420
return (PyObject *)marker;
14211421
}
14221422

1423+
/* ============================================================================
1424+
* InlineScheduleMarker - marker type for inline continuation without messaging
1425+
*
1426+
* When a Python handler returns an InlineScheduleMarker, the NIF detects it
1427+
* and uses enif_schedule_nif() to continue execution directly, bypassing
1428+
* the Erlang messaging layer for better performance in tight loops.
1429+
*
1430+
* Note: InlineScheduleMarkerObject is forward declared in py_nif.c
1431+
* ============================================================================ */
1432+
1433+
static void InlineScheduleMarker_dealloc(InlineScheduleMarkerObject *self) {
1434+
Py_XDECREF(self->module);
1435+
Py_XDECREF(self->func);
1436+
Py_XDECREF(self->args);
1437+
Py_XDECREF(self->kwargs);
1438+
Py_XDECREF(self->globals);
1439+
Py_XDECREF(self->locals);
1440+
Py_TYPE(self)->tp_free((PyObject *)self);
1441+
}
1442+
1443+
static PyObject *InlineScheduleMarker_repr(InlineScheduleMarkerObject *self) {
1444+
return PyUnicode_FromFormat("<erlang.InlineScheduleMarker module='%U' func='%U'>",
1445+
self->module, self->func);
1446+
}
1447+
1448+
static PyTypeObject InlineScheduleMarkerType = {
1449+
PyVarObject_HEAD_INIT(NULL, 0)
1450+
.tp_name = "erlang.InlineScheduleMarker",
1451+
.tp_doc = "Marker for inline continuation via enif_schedule_nif (no Erlang messaging)",
1452+
.tp_basicsize = sizeof(InlineScheduleMarkerObject),
1453+
.tp_itemsize = 0,
1454+
.tp_flags = Py_TPFLAGS_DEFAULT,
1455+
.tp_dealloc = (destructor)InlineScheduleMarker_dealloc,
1456+
.tp_repr = (reprfunc)InlineScheduleMarker_repr,
1457+
};
1458+
1459+
/**
1460+
* Check if a Python object is an InlineScheduleMarker
1461+
*/
1462+
static int is_inline_schedule_marker(PyObject *obj) {
1463+
return Py_IS_TYPE(obj, &InlineScheduleMarkerType);
1464+
}
1465+
1466+
/**
1467+
* @brief Python: erlang.schedule_inline(module, func, args=None, kwargs=None) -> InlineScheduleMarker
1468+
*
1469+
* Creates an InlineScheduleMarker that, when returned from a handler function,
1470+
* causes the NIF to use enif_schedule_nif() to continue execution directly
1471+
* without going through Erlang messaging.
1472+
*
1473+
* This is more efficient than schedule_py() for tight loops that need to yield
1474+
* to the scheduler but don't need to interact with Erlang between calls.
1475+
*
1476+
* Flow comparison:
1477+
* schedule_py: Python -> NIF -> Erlang message -> NIF -> Python
1478+
* schedule_inline: Python -> NIF -> enif_schedule_nif -> NIF -> Python
1479+
*
1480+
* Usage:
1481+
* def process_batch(data, offset=0, results=None):
1482+
* if results is None:
1483+
* results = []
1484+
* chunk_end = min(offset + 100, len(data))
1485+
* for i in range(offset, chunk_end):
1486+
* results.append(transform(data[i]))
1487+
* if chunk_end < len(data):
1488+
* if erlang.consume_time_slice(25):
1489+
* return erlang.schedule_inline('__main__', 'process_batch',
1490+
* args=[data, chunk_end, results])
1491+
* return process_batch(data, chunk_end, results)
1492+
* return results
1493+
*
1494+
* @param self Module reference (unused)
1495+
* @param args Positional args: (module, func)
1496+
* @param kwargs Keyword args: args=list/tuple, kwargs=dict
1497+
* @return InlineScheduleMarker object or NULL with exception
1498+
*/
1499+
static PyObject *py_schedule_inline(PyObject *self, PyObject *args, PyObject *kwargs) {
1500+
(void)self;
1501+
1502+
static char *kwlist[] = {"module", "func", "args", "kwargs", NULL};
1503+
PyObject *module_name = NULL;
1504+
PyObject *func_name = NULL;
1505+
PyObject *call_args = Py_None;
1506+
PyObject *call_kwargs = Py_None;
1507+
1508+
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO|OO", kwlist,
1509+
&module_name, &func_name, &call_args, &call_kwargs)) {
1510+
return NULL;
1511+
}
1512+
1513+
/* Validate module and func are strings */
1514+
if (!PyUnicode_Check(module_name)) {
1515+
PyErr_SetString(PyExc_TypeError, "module must be a string");
1516+
return NULL;
1517+
}
1518+
if (!PyUnicode_Check(func_name)) {
1519+
PyErr_SetString(PyExc_TypeError, "func must be a string");
1520+
return NULL;
1521+
}
1522+
1523+
/* Validate args is None or a sequence */
1524+
if (call_args != Py_None && !PyTuple_Check(call_args) && !PyList_Check(call_args)) {
1525+
PyErr_SetString(PyExc_TypeError, "args must be None, a tuple, or a list");
1526+
return NULL;
1527+
}
1528+
1529+
/* Validate kwargs is None or a dict */
1530+
if (call_kwargs != Py_None && !PyDict_Check(call_kwargs)) {
1531+
PyErr_SetString(PyExc_TypeError, "kwargs must be None or a dict");
1532+
return NULL;
1533+
}
1534+
1535+
/* Create the marker */
1536+
InlineScheduleMarkerObject *marker = PyObject_New(InlineScheduleMarkerObject, &InlineScheduleMarkerType);
1537+
if (marker == NULL) {
1538+
return NULL;
1539+
}
1540+
1541+
Py_INCREF(module_name);
1542+
marker->module = module_name;
1543+
1544+
Py_INCREF(func_name);
1545+
marker->func = func_name;
1546+
1547+
/* Convert args to tuple if it's a list */
1548+
if (call_args == Py_None) {
1549+
Py_INCREF(Py_None);
1550+
marker->args = Py_None;
1551+
} else if (PyList_Check(call_args)) {
1552+
marker->args = PyList_AsTuple(call_args);
1553+
if (marker->args == NULL) {
1554+
Py_DECREF(marker);
1555+
return NULL;
1556+
}
1557+
} else {
1558+
Py_INCREF(call_args);
1559+
marker->args = call_args;
1560+
}
1561+
1562+
Py_INCREF(call_kwargs);
1563+
marker->kwargs = call_kwargs;
1564+
1565+
/* Capture globals and locals from caller's frame */
1566+
PyObject *frame_globals = PyEval_GetGlobals(); /* Borrowed reference */
1567+
PyObject *frame_locals = PyEval_GetLocals(); /* Borrowed reference */
1568+
if (frame_globals != NULL) {
1569+
Py_INCREF(frame_globals);
1570+
marker->globals = frame_globals;
1571+
} else {
1572+
marker->globals = NULL;
1573+
}
1574+
if (frame_locals != NULL) {
1575+
Py_INCREF(frame_locals);
1576+
marker->locals = frame_locals;
1577+
} else {
1578+
marker->locals = NULL;
1579+
}
1580+
1581+
return (PyObject *)marker;
1582+
}
1583+
14231584
/**
14241585
* @brief Python: erlang.consume_time_slice(percent) -> bool
14251586
*
@@ -2484,6 +2645,10 @@ static PyMethodDef ErlangModuleMethods[] = {
24842645
"Schedule Python function continuation (must be returned from handler).\n\n"
24852646
"Usage: return erlang.schedule_py('module', 'func', [args], {'kwargs'})\n"
24862647
"Releases dirty scheduler and continues via _execute_py callback."},
2648+
{"schedule_inline", (PyCFunction)py_schedule_inline, METH_VARARGS | METH_KEYWORDS,
2649+
"Schedule inline Python continuation via enif_schedule_nif (no Erlang messaging).\n\n"
2650+
"Usage: return erlang.schedule_inline('module', 'func', args=[...], kwargs={...})\n"
2651+
"More efficient than schedule_py for tight loops that don't need Erlang interaction."},
24872652
{"consume_time_slice", py_consume_time_slice, METH_VARARGS,
24882653
"Check/consume NIF time slice for cooperative scheduling.\n\n"
24892654
"Usage: if erlang.consume_time_slice(percent): return erlang.schedule_py(...)\n"
@@ -2587,6 +2752,11 @@ static int create_erlang_module(void) {
25872752
return -1;
25882753
}
25892754

2755+
/* Initialize InlineScheduleMarker type */
2756+
if (PyType_Ready(&InlineScheduleMarkerType) < 0) {
2757+
return -1;
2758+
}
2759+
25902760
PyObject *module = PyModule_Create(&ErlangModuleDef);
25912761
if (module == NULL) {
25922762
return -1;
@@ -2646,6 +2816,14 @@ static int create_erlang_module(void) {
26462816
return -1;
26472817
}
26482818

2819+
/* Add InlineScheduleMarker type to module */
2820+
Py_INCREF(&InlineScheduleMarkerType);
2821+
if (PyModule_AddObject(module, "InlineScheduleMarker", (PyObject *)&InlineScheduleMarkerType) < 0) {
2822+
Py_DECREF(&InlineScheduleMarkerType);
2823+
Py_DECREF(module);
2824+
return -1;
2825+
}
2826+
26492827
/* Add __getattr__ to enable "from erlang import name" and "erlang.name()" syntax
26502828
* Module __getattr__ (PEP 562) needs to be set as an attribute on the module dict */
26512829
PyObject *getattr_func = PyCFunction_New(&getattr_method, module);

c_src/py_exec.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,13 @@ static void process_request(py_request_t *req) {
329329
req->result = enif_make_tuple2(env, ATOM_OK,
330330
enif_make_tuple2(env, ATOM_GENERATOR, gen_ref));
331331
}
332+
} else if (is_inline_schedule_marker(py_result)) {
333+
/* Inline schedule marker not supported in legacy worker NIFs.
334+
* Note: py:call() uses the context API (nif_context_call), which
335+
* does support schedule_inline. This code path is only hit by
336+
* direct py_nif:worker_call usage, which is rare. */
337+
Py_DECREF(py_result);
338+
req->result = make_error(env, "schedule_inline_not_supported_in_worker_mode");
332339
} else if (is_schedule_marker(py_result)) {
333340
/* Schedule marker: release dirty scheduler, continue via callback */
334341
ScheduleMarkerObject *marker = (ScheduleMarkerObject *)py_result;
@@ -424,6 +431,11 @@ static void process_request(py_request_t *req) {
424431
req->result = enif_make_tuple2(env, ATOM_OK,
425432
enif_make_tuple2(env, ATOM_GENERATOR, gen_ref));
426433
}
434+
} else if (is_inline_schedule_marker(py_result)) {
435+
/* Inline schedule marker not supported in legacy worker NIFs.
436+
* Note: py:call() uses the context API, which supports schedule_inline. */
437+
Py_DECREF(py_result);
438+
req->result = make_error(env, "schedule_inline_not_supported_in_worker_mode");
427439
} else if (is_schedule_marker(py_result)) {
428440
/* Schedule marker: release dirty scheduler, continue via callback */
429441
ScheduleMarkerObject *marker = (ScheduleMarkerObject *)py_result;

0 commit comments

Comments
 (0)