Skip to content

feat(ffi): slab-allocate event payloads instead of c_malloc per dispatch#107

Merged
gmelodie merged 1 commit into
masterfrom
feat/slab-event-payloads
Jul 6, 2026
Merged

feat(ffi): slab-allocate event payloads instead of c_malloc per dispatch#107
gmelodie merged 1 commit into
masterfrom
feat/slab-event-payloads

Conversation

@gmelodie

@gmelodie gmelodie commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #92.

Under high-rate event emission every dispatchFFIEvent / dispatchFFIEventCbor cost two c_mallocs and two c_frees — one for the payload, one for the name string — churning the process-global allocator on the hot path. This replaces the per-event allocations with preallocated, reused per-slot buffers so steady-state emission allocates nothing.

Each EventQueue ring slot now owns two c_malloc-backed buffers, allocated once in initEventQueue and freed once in deinitEventQueue:

  • a payload buffer sized to MaxEventPayloadBytes (default 512), and
  • a name buffer sized to MaxEventNameBytes (default 64).

tryEnqueueEvent copies the name and payload into the tail slot's buffers instead of adopting caller-owned c_malloc pointers. A value that overflows its slot budget falls back to a one-off c_malloc tagged nameHeapOwned / dataHeapOwned and freed on commit, so the slab stays small without sizing for the worst case. Buffers remain c_malloc-backed (not allocShared) so the event thread can read a slot after the producing FFI thread's heap is gone — the same TLS hazard documented in alloc.nim.

EventQueueCapacity, MaxEventPayloadBytes, and MaxEventNameBytes are {.intdefine.} consts, overridable per built library (-d:MaxEventPayloadBytes=N, etc.).

Two intentional divergences from the issue

  1. The issue's 3a proposed interning names and never freeing them. That leaks under this repo's LSan CI (detect_leaks=1), so instead each slot reuses a name buffer freed at deinit — still allocation-free in steady state, but leak-clean. A leak-free process-global intern would require cross-thread-mutable GC state, which this codebase deliberately avoids.
  2. The issue states slab reuse is safe "with the full check." It isn't under a stalled consumer plus a full ring: the single producer can overwrite a slab slot while a slow listener is still reading it. The drain path is now peek → dispatch → commit (peekEvent / commitDequeue), keeping the in-flight slot counted (and thus un-reusable) until dispatch returns. This is what keeps the producer/consumer handoff TSan-clean.

Tests

  • test_event_dispatch.nim, test_event_thread.nim, test_event_listener.nim stay green (orc and refc).
  • Added oversize payload falls back to heap and oversize event name falls back to heap, covering both heap-fallback branches end-to-end.
  • Verified locally: orc + refc pass; the oversize path is ASan/UBSan/LSan clean (detect_leaks=1); the overflow/backpressure handoff in test_event_thread.nim is TSan clean.

@Ivansete-status Ivansete-status left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks for it! 🙌
With regards to the event management, we need to invest efforts in the following, which basically aims to avoid dropping events (avoid fixed-sized ring) while keeping memory steady: #112

@gmelodie
gmelodie merged commit 08509cc into master Jul 6, 2026
109 checks passed
@gmelodie
gmelodie deleted the feat/slab-event-payloads branch July 6, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slab-allocate event payloads instead of c_malloc per dispatch

2 participants