Skip to content

Bind the pipeline the batch source stands on - #157

Merged
martin-s-a merged 2 commits into
mainfrom
bind-the-batch-pipeline
Sep 20, 2026
Merged

martin-s-a merged 2 commits into
mainfrom
bind-the-batch-pipeline

Conversation

@martin-s-a

Copy link
Copy Markdown
Contributor

Closes #154. Item 3b of the backlog, and the half with the threads in it.

image_batch_source is the class meant for pipelines: asynchronous, batched,
running its own threads. The slow path stays what it is, for one-off reads.

source = rexlib.em.image.batch_source(workers=8)

destination = rexlib.zeros(
    rexlib.make_contiguous_array_descriptor(
        (len(locations), *rexlib.em.image.query_core_extents(path)),
        rexlib.NumericalType.float32,
    ),
    rexlib.hardware.MemoryResourceAffinity.host,
    context,
)

completion = source.read(destination, locations)
completion.get()

Reaching that meant binding the chain beneath it — Executor and its two
kinds, ImageReaderProvider and its two, ImageSource — and Completion,
which is what read hands back. Executor and Completion live in a new
_binding.concurrency, following src/core/concurrency/.

The GIL, which is the whole point

Completion.wait, Completion.get and ImageBatchSource.read take
py::call_guard<py::gil_scoped_release>().

This is not a refinement. Held through a read, the GIL freezes every other
Python thread for its duration, and there is nothing asynchronous left about
the interface — ordering a batch and blocking the interpreter is what the slow
path already does. pybind11 converts the arguments before the guard is
constructed and the return value after it is destroyed, so nothing touches
Python without it.

read gets the same treatment because it opens files through the provider
before returning, even though it returns before the reads are done.

The destination is shared, not moved

read takes its destination by value and rexlib::array is move-only. Bound
plainly, pybind11 would move the array out of the caller's Python object and
hand them back an empty one — silently, and only noticed later. The binding
takes array& and passes destination.share(), which is a second handle onto
the same storage. A test pins it: the destination still reports its shape
after the read.

Assembly

batch_source(workers=…, cache=…, manager=…, executor=…) wires the formats, a
provider, an executor and the source, so a caller reaches the pipeline without
naming four objects. Every argument has a default and every one can be
overridden; the parts are all exported for anyone who wants to assemble them
by hand, and a test does exactly that.

Each source built this way owns its executor, which is the settled decision:
sharing a pool between sources is the caller's business, done by passing one
executor to both, and per-instance is the direction that stays cheap to
reverse. cache=N swaps the direct provider for a caching one.

The query_extents and query_core_extents overloads over a provider arrive
here too, having waited for the provider to exist.

Checked

  • All 47 translation units compile clean under
    g++ -std=c++20 -fsyntax-only -Wall -Wextra -Wpedantic against the pinned
    submodule.
  • ruff check . passes; 56 tests collect under tests/em/image/.
  • The tests cover a batch read end to end, two batches in flight collected
    together, an empty batch resolving without touching the source, a wrong
    batch size refused, the synchronous executor, a caching provider serving a
    repeated read, and assembly by hand.

🤖 Generated with Claude Code

Closes #154.

image_batch_source is the class meant for pipelines: asynchronous, batched,
and running its own threads. Reaching it means binding the chain under it —
the executors, the reader providers, the source — and the completion it hands
back.

Completion.wait, Completion.get and ImageBatchSource.read release the GIL.
Held through a read, it would freeze every other Python thread for the
duration and leave nothing asynchronous about the interface.

read takes its destination by value and array is move-only, so the binding
shares the caller's array rather than moving out of it, which would have left
them holding an empty one.

batch_source() assembles the four objects for a caller who wants the pipeline
rather than the parts. Each source it builds owns its executor; sharing one
between sources is done by passing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@martin-s-a martin-s-a added the enhancement New feature or request label Sep 20, 2026
@martin-s-a
martin-s-a requested a review from ratolon as a code owner September 20, 2026 18:44
@martin-s-a martin-s-a self-assigned this Sep 20, 2026
@martin-s-a
martin-s-a enabled auto-merge (squash) September 20, 2026 18:54
martin-s-a added a commit to gigabit-clowns/rexlib that referenced this pull request Sep 20, 2026
Four classes carry `REXLIB_API` on their methods but not on the class
itself,
so their typeinfo never leaves the shared object:

- `thread_pool_executor` and `synchronous_executor`
- `direct_image_reader_provider` and `caching_image_reader_provider`

Their interfaces, `executor` and `image_reader_provider`, are both
`REXLIB_API`. The implementations are not, which leaves each hierarchy
exported halfway.

## Why it has never shown

The library builds with hidden visibility, and a polymorphic class emits
its
typeinfo in the translation unit that defines its first out-of-line
virtual —
inside the library. Without `REXLIB_API` on the class, that symbol is
`HIDDEN`
and nothing outside can link against it.

Ordinary C++ use never asks for it: constructing one of these, calling
through
it, destroying it, all go through the methods, and those are exported
one by
one. It only bites something that reaches for RTTI across the boundary,
which
is what a language binding does — pybind11 calls `typeid()` on every
type it
registers.

It surfaced in gigabit-clowns/rexlib-python#157, as a load-time failure
of the
extension with no build warning ahead of it:

```
ImportError: _binding.cpython-314-x86_64-linux-gnu.so:
undefined symbol: _ZTIN6rexlib20thread_pool_executorE
```

which demangles to `typeinfo for rexlib::thread_pool_executor`.

## The change, and the evidence

One keyword on each of the four classes. Measured on
`thread_pool_executor.cpp`, compiled with the same
`-fvisibility=hidden -fvisibility-inlines-hidden` the build uses:

| | `_ZTIN6rexlib20thread_pool_executorE` |
|---|---|
| before | `HIDDEN` |
| after | `DEFAULT` |

The symbol was emitted either way; what changed is whether it crosses
the
boundary.

`image_source` and `image_batch_source` are left alone deliberately.
Neither
is polymorphic, so their typeinfo is a weak symbol any consumer emits
for
itself, and nothing is missing.

The per-method `REXLIB_API` on these classes is now redundant but left
in
place: `image_read_format_manager` and `image_reader_provider` both
already
carry the class attribute and repeat it on members, so removing it here
would
be a larger diff against a convention this does not settle.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
rexlib #463 exports the implementations behind the interfaces this branch
binds, so the typeinfo the cast needs is visible outside the library. The
pin is moved rather than left to `git submodule update --remote` so that a
clone of this branch builds against the same rexlib CI does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@martin-s-a
martin-s-a merged commit abb3000 into main Sep 20, 2026
36 checks passed
@martin-s-a
martin-s-a deleted the bind-the-batch-pipeline branch September 20, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bind image_batch_source, and two questions before it

2 participants