Operator-registration hardening#
Status: implemented (branch worktree-fix-op-registration-audit).
Problem#
An audit of the custom-operator registration machinery under brainevent/_op
catalogued 19 defects (F1–F19). They share a theme: the registration and
dispatch layer failed silently. A wrong backend, a dropped gradient, a stale
compiled artefact, or a mis-batched CUDA launch produced a plausible-looking
number rather than an error, so none of them were visible from the test suite’s
pass/fail signal alone.
The findings cluster into five groups.
F1 — backend switches did not take effect#
XLACustomKernel.set_default, brainevent.config.set_backend and
clear_backends mutated brainevent’s own registry but left JAX’s dispatch and
executable caches untouched. Eager calls and already-traced jax.jit functions
kept running the previous backend, so a user switching from cuda_raw to
jax_raw to isolate a numerical discrepancy would measure the same kernel
twice.
F2 — defjvp silently dropped JVP rules#
defjvp paired rules with tangents via zip(jvp_rules, tangents). zip stops
at the shorter sequence, so registering fewer rules than the primitive has
inputs dropped the trailing inputs’ tangent contributions with no error — an
incorrect gradient, silently. The multi-result path additionally accepted a
bare array where a sequence was required and fragmented it by iterating its
leading axis.
One in-tree instance existed: binary_fcnmm_p registered four rules for a
three-input primitive.
F3, F4, F10, F12 — incomplete compilation-cache keys#
The kernix (inline C++/CUDA) cache key hashed only the user’s source text. It therefore ignored:
the resolved
FunctionSpecs (F3) — loading the same source first with one function and then with two hit the single-function.so, which lacks the second wrapper symbol, and failed with a misleading “Did the compilation succeed?” message;the content of headers reachable through
extra_include_paths(F10) — editing such a header was invisible to the cache.
Separately, re-registering a changed artefact under an existing FFI target name
(F4, F12) had no well-defined behaviour: force_rebuild=True and replace=True
could leave the old handler installed and keep dispatching stale code.
F5, F9 — vmap over numba.cuda kernels computed wrong results#
Batched calls reused the launch grid computed for the unbatched shape and ran
it once over the folded buffer. Any kernel that couples rows — stencils,
reductions, segment scans — silently produced corrupt output for all but the
first slice. Nested vmap returned uninitialized memory.
F6–F8, F11, F13–F19 — diagnostics and identity#
FFI target names were assigned from a per-process counter, so jax.export
artefacts were not stable across processes and a recycled Python object id
could mis-dispatch. Failure modes surfaced as bare asserts, KeyErrors, or
silent fallbacks rather than typed, actionable errors.
Approach#
Invalidate on effective change, not on every call. Backend setters compare
the resolved setting before and after; only a real change triggers
jax.clear_caches(). The invalidation is process-global — the next call of
every jitted function recompiles — which is the correct trade for a setting
that is changed rarely and interactively.
Fail loudly at the earliest point where the mistake is still attributable.
defjvp checks rule/input arity at registration-time semantics (before the
zip) and raises ValueError naming the primitive; a multi-result rule
returning a bare array raises TypeError. Unknown, packed sub-byte
(S1–S4, U1–U4, F4E2M1FN) and FP8 buffer dtypes raise instead of being
reinterpreted as raw bytes.
Make registration identity deterministic. The cache key (schema v2) covers
the resolved FunctionSpecs and the content of extra include headers; the
deterministic cache key is the registration identity. Two consequences follow
directly:
re-registering byte-identical content (e.g.
force_rebuild=Truetwice) is an idempotent no-op, not an error;re-registering changed content under an existing target name raises
KernelRegistrationErroron every platform.
The second is a deliberate behaviour change. Live re-pointing of an
already-registered XLA FFI target is not supported by the JAX versions this
package targets — probed directly: the Host registry rejects a differing bundle
address, and the CUDA registry accepts the call but silently keeps the old
handler. Since a correct re-point cannot be verified, brainevent refuses
deterministically and the error names both remedies (replace=True, or a
distinct name= / target_prefix=). Refusing is strictly better than the
pre-fix behaviour of appearing to succeed while dispatching stale code.
Derive FFI target names from content. Names are a fingerprint of the
kernel’s bytecode, constants, closure values and referenced globals
(dispatcher-aware), which makes jax.export artefacts stable across processes.
Kernels whose content cannot be fingerprinted deterministically fall back to
per-process counter names — the pre-fix behaviour, now the exception rather than
the rule. Reuse branches pin the kernel object so a recycled id cannot
mis-dispatch, and handlers self-pin at construction.
Batch numba.cuda by launching per slice. Rank-based detection identifies
the batched case and issues one launch per batch slice with the kernel’s
original launch configuration. Kernels wrapped with an explicit grid= cannot
be batched (combining grid= with vmap_method= raises ValueError at wrap
time); nested vmap raises rather than returning garbage. CUDA output buffers
for accumulating kernels are zero-filled on XLA’s stream.
Behaviour changes#
Two changes are visible to users and are recorded in changelog.md:
Re-registering an FFI target with different content raises
KernelRegistrationErroron every platform, includingload_cuda_inline(..., replace=True)andforce_rebuild=Truewith edited source. To iterate on a kernel within one process, register under a newname=.Kernel construction/compile failures during lowering raise
KernelCompilationError, with the original exception as__cause__and the remaining registered backends listed. Calling a kernel on a platform with no registered backend raisesKernelFallbackExhaustedError. Both are exported frombrainevent.
Change 2 has one in-tree consequence: the removed CUDA row-gather matvec raised
a bare NotImplementedError from its construction path, and
_fcn/binary_test.py::test_ell_binary_matvec_forward_matches_reference asserted
on that exact type. The message (and thus the match='row-gather' assertion) is
preserved inside the wrapper, so the test now accepts either.
Registering a second primitive under an existing name emits a UserWarning; the
new registration still wins, as before.
Verification#
Every finding carries a regression test that fails against the pre-fix code:
Area |
Test module |
|---|---|
Backend-switch invalidation (F1) |
|
|
|
Cache keys, re-registration (F3, F4, F10, F12) |
|
|
|
Naming, typed errors (F6–F8, F11, F13–F19) |
|
The F3/F4/F10/F12 end-to-end tests exercise cache-hit behaviour, so each takes
an isolated_cache fixture pointing the compilation cache at a scratch
directory. Without it, another test in the session can pre-populate the cache
and the test passes for the wrong reason.
The F1/F17 probes register kernels for 'cpu' only. Their input arrays must be
committed with jax.device_put(..., jax.devices('cpu')[0]): on a machine with a
GPU, default placement lowers the primitive for 'gpu' and the call dies with
KernelFallbackExhaustedError before the behaviour under test is reached. These
tests were originally written against a CPU-only run and passed for that reason;
a full run on CUDA hardware is what exposed the omission.
The F11/F15 cache tests must derive the artefact name from so_ext(). store
publishes as <name><so_ext()> — .so, .dylib or .dll by platform — not
under the source file’s name, so a hard-coded "cc.so" destination or an
endswith(".so") guard silently does nothing on macOS and Windows: the F15
guard never fires and the publish succeeds where the test expects a raise.
Linux CI cannot catch this class of bug at all.
CPU-only runs cannot cover the CUDA paths — every CUDA test is gated behind
requires_gpu and .cu/inline-CUDA sources are never compiled unless a GPU
kernel is lowered. Validation therefore requires a run on CUDA hardware in
addition to the CPU suite and the mypy gate.
Known gaps#
brainevent/_op/ffi_naming.pyhas no siblingffi_naming_test.py. Its behaviour is exercised indirectly throughnumba_ffi_test.pyandnumba_cuda_ffi_test.py, which is adequate coverage but does not satisfy the co-location rule. Splitting out a direct test module is a follow-up.The audit’s own working notes (
dev/2026-07-16-op-registration-audit.md, referenced by the implementing commit message) were never committed; this document supersedes them.