Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
# Changelog

## 2.2.0 (2026-03-13)
## 2.2.0 (unreleased)

### Added

- **Inline Continuation API** - High-performance scheduling without Erlang messaging
- `erlang.schedule_inline(module, func, args, kwargs)` - Chain Python calls via `enif_schedule_nif()`
- ~3x faster than `schedule_py` for tight loops (bypasses gen_server messaging)
- Captures caller's globals/locals for correct namespace resolution with subinterpreters
- `InlineScheduleMarker` type returned, must be returned from handler
- See [Scheduling API docs](docs/asyncio.md#explicit-scheduling-api)

- **Inline Continuation Benchmark** - Performance comparison
- `bench_schedule_inline` in `examples/benchmark.erl`
- Compares `schedule_inline` vs `schedule_py` throughput

- **Process-Bound Python Environments** - Each Erlang process gets an isolated Python namespace
- `py:get_local_env/1` - Get or create a process-local Python environment
- Variables defined via `py:exec()` persist across calls within the same Erlang process
Expand Down
178 changes: 178 additions & 0 deletions c_src/py_callback.c
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,167 @@ static PyObject *py_schedule_py(PyObject *self, PyObject *args, PyObject *kwargs
return (PyObject *)marker;
}

/* ============================================================================
* InlineScheduleMarker - marker type for inline continuation without messaging
*
* When a Python handler returns an InlineScheduleMarker, the NIF detects it
* and uses enif_schedule_nif() to continue execution directly, bypassing
* the Erlang messaging layer for better performance in tight loops.
*
* Note: InlineScheduleMarkerObject is forward declared in py_nif.c
* ============================================================================ */

static void InlineScheduleMarker_dealloc(InlineScheduleMarkerObject *self) {
Py_XDECREF(self->module);
Py_XDECREF(self->func);
Py_XDECREF(self->args);
Py_XDECREF(self->kwargs);
Py_XDECREF(self->globals);
Py_XDECREF(self->locals);
Py_TYPE(self)->tp_free((PyObject *)self);
}

static PyObject *InlineScheduleMarker_repr(InlineScheduleMarkerObject *self) {
return PyUnicode_FromFormat("<erlang.InlineScheduleMarker module='%U' func='%U'>",
self->module, self->func);
}

static PyTypeObject InlineScheduleMarkerType = {
PyVarObject_HEAD_INIT(NULL, 0)
.tp_name = "erlang.InlineScheduleMarker",
.tp_doc = "Marker for inline continuation via enif_schedule_nif (no Erlang messaging)",
.tp_basicsize = sizeof(InlineScheduleMarkerObject),
.tp_itemsize = 0,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_dealloc = (destructor)InlineScheduleMarker_dealloc,
.tp_repr = (reprfunc)InlineScheduleMarker_repr,
};

/**
* Check if a Python object is an InlineScheduleMarker
*/
static int is_inline_schedule_marker(PyObject *obj) {
return Py_IS_TYPE(obj, &InlineScheduleMarkerType);
}

/**
* @brief Python: erlang.schedule_inline(module, func, args=None, kwargs=None) -> InlineScheduleMarker
*
* Creates an InlineScheduleMarker that, when returned from a handler function,
* causes the NIF to use enif_schedule_nif() to continue execution directly
* without going through Erlang messaging.
*
* This is more efficient than schedule_py() for tight loops that need to yield
* to the scheduler but don't need to interact with Erlang between calls.
*
* Flow comparison:
* schedule_py: Python -> NIF -> Erlang message -> NIF -> Python
* schedule_inline: Python -> NIF -> enif_schedule_nif -> NIF -> Python
*
* Usage:
* def process_batch(data, offset=0, results=None):
* if results is None:
* results = []
* chunk_end = min(offset + 100, len(data))
* for i in range(offset, chunk_end):
* results.append(transform(data[i]))
* if chunk_end < len(data):
* if erlang.consume_time_slice(25):
* return erlang.schedule_inline('__main__', 'process_batch',
* args=[data, chunk_end, results])
* return process_batch(data, chunk_end, results)
* return results
*
* @param self Module reference (unused)
* @param args Positional args: (module, func)
* @param kwargs Keyword args: args=list/tuple, kwargs=dict
* @return InlineScheduleMarker object or NULL with exception
*/
static PyObject *py_schedule_inline(PyObject *self, PyObject *args, PyObject *kwargs) {
(void)self;

static char *kwlist[] = {"module", "func", "args", "kwargs", NULL};
PyObject *module_name = NULL;
PyObject *func_name = NULL;
PyObject *call_args = Py_None;
PyObject *call_kwargs = Py_None;

if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO|OO", kwlist,
&module_name, &func_name, &call_args, &call_kwargs)) {
return NULL;
}

/* Validate module and func are strings */
if (!PyUnicode_Check(module_name)) {
PyErr_SetString(PyExc_TypeError, "module must be a string");
return NULL;
}
if (!PyUnicode_Check(func_name)) {
PyErr_SetString(PyExc_TypeError, "func must be a string");
return NULL;
}

/* Validate args is None or a sequence */
if (call_args != Py_None && !PyTuple_Check(call_args) && !PyList_Check(call_args)) {
PyErr_SetString(PyExc_TypeError, "args must be None, a tuple, or a list");
return NULL;
}

/* Validate kwargs is None or a dict */
if (call_kwargs != Py_None && !PyDict_Check(call_kwargs)) {
PyErr_SetString(PyExc_TypeError, "kwargs must be None or a dict");
return NULL;
}

/* Create the marker */
InlineScheduleMarkerObject *marker = PyObject_New(InlineScheduleMarkerObject, &InlineScheduleMarkerType);
if (marker == NULL) {
return NULL;
}

Py_INCREF(module_name);
marker->module = module_name;

Py_INCREF(func_name);
marker->func = func_name;

/* Convert args to tuple if it's a list */
if (call_args == Py_None) {
Py_INCREF(Py_None);
marker->args = Py_None;
} else if (PyList_Check(call_args)) {
marker->args = PyList_AsTuple(call_args);
if (marker->args == NULL) {
Py_DECREF(marker);
return NULL;
}
} else {
Py_INCREF(call_args);
marker->args = call_args;
}

Py_INCREF(call_kwargs);
marker->kwargs = call_kwargs;

/* Capture globals and locals from caller's frame */
PyObject *frame_globals = PyEval_GetGlobals(); /* Borrowed reference */
PyObject *frame_locals = PyEval_GetLocals(); /* Borrowed reference */
if (frame_globals != NULL) {
Py_INCREF(frame_globals);
marker->globals = frame_globals;
} else {
marker->globals = NULL;
}
if (frame_locals != NULL) {
Py_INCREF(frame_locals);
marker->locals = frame_locals;
} else {
marker->locals = NULL;
}

return (PyObject *)marker;
}

/**
* @brief Python: erlang.consume_time_slice(percent) -> bool
*
Expand Down Expand Up @@ -2484,6 +2645,10 @@ static PyMethodDef ErlangModuleMethods[] = {
"Schedule Python function continuation (must be returned from handler).\n\n"
"Usage: return erlang.schedule_py('module', 'func', [args], {'kwargs'})\n"
"Releases dirty scheduler and continues via _execute_py callback."},
{"schedule_inline", (PyCFunction)py_schedule_inline, METH_VARARGS | METH_KEYWORDS,
"Schedule inline Python continuation via enif_schedule_nif (no Erlang messaging).\n\n"
"Usage: return erlang.schedule_inline('module', 'func', args=[...], kwargs={...})\n"
"More efficient than schedule_py for tight loops that don't need Erlang interaction."},
{"consume_time_slice", py_consume_time_slice, METH_VARARGS,
"Check/consume NIF time slice for cooperative scheduling.\n\n"
"Usage: if erlang.consume_time_slice(percent): return erlang.schedule_py(...)\n"
Expand Down Expand Up @@ -2587,6 +2752,11 @@ static int create_erlang_module(void) {
return -1;
}

/* Initialize InlineScheduleMarker type */
if (PyType_Ready(&InlineScheduleMarkerType) < 0) {
return -1;
}

PyObject *module = PyModule_Create(&ErlangModuleDef);
if (module == NULL) {
return -1;
Expand Down Expand Up @@ -2646,6 +2816,14 @@ static int create_erlang_module(void) {
return -1;
}

/* Add InlineScheduleMarker type to module */
Py_INCREF(&InlineScheduleMarkerType);
if (PyModule_AddObject(module, "InlineScheduleMarker", (PyObject *)&InlineScheduleMarkerType) < 0) {
Py_DECREF(&InlineScheduleMarkerType);
Py_DECREF(module);
return -1;
}

/* Add __getattr__ to enable "from erlang import name" and "erlang.name()" syntax
* Module __getattr__ (PEP 562) needs to be set as an attribute on the module dict */
PyObject *getattr_func = PyCFunction_New(&getattr_method, module);
Expand Down
12 changes: 12 additions & 0 deletions c_src/py_exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,13 @@ static void process_request(py_request_t *req) {
req->result = enif_make_tuple2(env, ATOM_OK,
enif_make_tuple2(env, ATOM_GENERATOR, gen_ref));
}
} else if (is_inline_schedule_marker(py_result)) {
/* Inline schedule marker not supported in legacy worker NIFs.
* Note: py:call() uses the context API (nif_context_call), which
* does support schedule_inline. This code path is only hit by
* direct py_nif:worker_call usage, which is rare. */
Py_DECREF(py_result);
req->result = make_error(env, "schedule_inline_not_supported_in_worker_mode");
} else if (is_schedule_marker(py_result)) {
/* Schedule marker: release dirty scheduler, continue via callback */
ScheduleMarkerObject *marker = (ScheduleMarkerObject *)py_result;
Expand Down Expand Up @@ -424,6 +431,11 @@ static void process_request(py_request_t *req) {
req->result = enif_make_tuple2(env, ATOM_OK,
enif_make_tuple2(env, ATOM_GENERATOR, gen_ref));
}
} else if (is_inline_schedule_marker(py_result)) {
/* Inline schedule marker not supported in legacy worker NIFs.
* Note: py:call() uses the context API, which supports schedule_inline. */
Py_DECREF(py_result);
req->result = make_error(env, "schedule_inline_not_supported_in_worker_mode");
} else if (is_schedule_marker(py_result)) {
/* Schedule marker: release dirty scheduler, continue via callback */
ScheduleMarkerObject *marker = (ScheduleMarkerObject *)py_result;
Expand Down
Loading
Loading