Skip to content

Commit 2633076

Browse files
committed
feat(c): non-scalar CBOR-free fast path
1 parent e60f771 commit 2633076

8 files changed

Lines changed: 473 additions & 111 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project are documented in this file.
55
## [Unreleased]
66

77
### Changed
8+
- **`abi = c` non-scalar procs no longer marshal through CBOR.** The foreign
9+
surface is unchanged — the generated headers and exported symbols are
10+
byte-identical — but the hop between the caller and the FFI thread now carries
11+
the packed `_CWire` struct itself instead of CBOR-encoding it and decoding it
12+
back. A request is packed into a `malloc`'d owned copy on the calling thread
13+
and unpacked (then freed) on the FFI thread; an object reply rides back as its
14+
`_CWire` image and a `string` reply as raw UTF-8, so the round trip through
15+
`cborEncodeShared`/`cborDecodePtr` is gone from both directions. Only the
16+
scalar fast path was CBOR-free before
17+
([#131](https://github.com/logos-messaging/nim-ffi/issues/131)).
18+
- `_CWire` seq/Option payload buffers are allocated with libc `malloc`
19+
(`cwireAllocBuf`) rather than `allocShared`, so a wire packed on the calling
20+
thread can be freed on the FFI thread — the cross-thread ownership the
21+
CBOR-free request path relies on, and consistent with the libc-backed request
22+
envelope.
823
- User event callbacks now run on a dedicated event thread fed by a
924
bounded SPSC queue (default capacity 1024), so a slow listener can no
1025
longer block the FFI thread or concurrent `add_event_listener` /

ffi/codegen/c.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,7 @@ proc emitAbiMethod(
12311231

12321232
func abiScalarOkLines(m: FFIProcMeta, fnType: string): seq[string] =
12331233
## Trampoline RET_OK branch. A string return rides as its own UTF-8; every
1234-
## other scalar is the 8-byte image `ffiScalarRetBytes` packs (ints
1234+
## other scalar is the 8-byte image `ffiRawRetBytes` packs (ints
12351235
## sign-extended, floats widened to double, bool as 0/1).
12361236
let rt = m.returnTypeName.strip()
12371237
if rt == "string" or rt == "cstring":

ffi/ffi_thread_request.nim

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ type FFIThreadRequest* = object
1717
callback*: FFICallBack
1818
userData*: pointer
1919
reqId*: cstring ## Req type name used to look up the handler.
20-
data*: ptr UncheckedArray[byte] ## Owned CBOR-encoded request payload.
20+
data*: ptr UncheckedArray[byte]
21+
## Owned request payload: CBOR-encoded, or a packed `_CWire` struct on the
22+
## `abi = c` path. Nil on the scalar fast path.
2123
dataLen*: int
22-
isScalar*: bool
23-
## Scalar fast path: args rode inline in `scalarArgs`, so a 0-length return
24-
## is a real empty string, not a CBOR "no value".
24+
rawReply*: bool
25+
## CBOR-free request (scalar fast path or `abi = c`): the reply is raw bytes,
26+
## so a 0-length one is a real empty string, not a CBOR "no value".
2527
scalarArgs*: array[MaxScalarArgs, uint64]
2628
## Inlined scalar args (no per-call c_malloc); a plain array keeps
2729
## `deleteRequest` unaliased.
@@ -63,7 +65,7 @@ proc allocBaseRequest(
6365
ret[].reqId = reqId.alloc()
6466
ret[].data = nil
6567
ret[].dataLen = 0
66-
ret[].isScalar = false
68+
ret[].rawReply = false
6769
ret[].next = nil
6870
ret[].responded = false
6971
return ret
@@ -121,11 +123,14 @@ proc initFromOwnedShared*(
121123
reqId: cstring,
122124
data: ptr UncheckedArray[byte],
123125
dataLen: int,
126+
rawReply: bool = false,
124127
): ptr type T =
125128
## Adopts an already-c_malloc'd buffer (no copy); `deleteRequest` c_frees it.
126-
## Pass `(nil, 0)` for an empty payload.
129+
## Pass `(nil, 0)` for an empty payload. Set `rawReply` when the handler answers
130+
## with raw (non-CBOR) bytes, so an empty reply isn't read as "no value".
127131
var ret = allocBaseRequest(callback, userData, reqId)
128132
adoptOwnedSharedPayload(ret, data, dataLen)
133+
ret[].rawReply = rawReply
129134
return ret
130135

131136
proc initScalar*(
@@ -140,14 +145,14 @@ proc initScalar*(
140145
"initScalar: " & $args.len & " scalar args exceed MaxScalarArgs (" & $MaxScalarArgs &
141146
")"
142147
var ret = allocBaseRequest(callback, userData, reqId)
143-
ret[].isScalar = true
148+
ret[].rawReply = true
144149
for i in 0 ..< args.len:
145150
ret[].scalarArgs[i] = args[i]
146151
ret
147152

148-
func ffiScalarRetBytes*[T](x: T): seq[byte] =
149-
## Scalar handler result as raw bytes, no CBOR: string/cstring ride as UTF-8,
150-
## other scalars as the 8-byte native image of `ffiPackScalar(x)`.
153+
func ffiRawRetBytes*[T](x: T): seq[byte] =
154+
## CBOR-free handler result as raw bytes: string/cstring ride as UTF-8, other
155+
## scalars as the 8-byte native image of `ffiPackScalar(x)`.
151156
when T is string:
152157
var b = newSeq[byte](x.len)
153158
if x.len > 0:
@@ -195,8 +200,8 @@ proc fireCallback*(res: Result[seq[byte], string], request: ptr FFIThreadRequest
195200
cast[csize_t](bytes.len),
196201
request[].userData,
197202
)
198-
elif request[].isScalar:
199-
# Scalar 0-byte return is a real empty string, not CBOR "no value".
203+
elif request[].rawReply:
204+
# A CBOR-free 0-byte return is a real empty string, not CBOR "no value".
200205
var empty: byte
201206
request[].callback(
202207
RET_OK, cast[ptr cchar](addr empty), 0.csize_t, request[].userData

ffi/internal/c_macro_helpers.nim

Lines changed: 64 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ proc markCWireEmitted(typeName: string) {.compileTime.} =
2222
if not isCWireEmitted(typeName):
2323
emittedCWireTypes.add(typeName)
2424

25-
proc cwireTypeName(userTypeName: string): string =
25+
proc cwireTypeName*(userTypeName: string): string =
2626
userTypeName & "_CWire"
2727

2828
proc seqItemsField(obj, field: NimNode): NimNode =
@@ -31,7 +31,7 @@ proc seqItemsField(obj, field: NimNode): NimNode =
3131
proc seqLenField(obj, field: NimNode): NimNode =
3232
newDotExpr(obj, ident($field & cwireLenSuffix))
3333

34-
proc isStringType(t: NimNode): bool =
34+
proc isStringType*(t: NimNode): bool =
3535
t.kind == nnkIdent and ($t == "string" or $t == "cstring")
3636

3737
proc isBracketOf(t: NimNode, heads: openArray[string]): bool =
@@ -294,12 +294,12 @@ proc emitSeqPack(dstObj, srcAccess, fieldNameIdent, userType: NimNode): NimNode
294294
`items` = nil
295295
`count` = 0
296296
else:
297-
`items` = cast[`bufType`](allocShared(sizeof(`wireElem`) * `srcAccess`.len()))
297+
`items` = cast[`bufType`](cwireAllocBuf(sizeof(`wireElem`) * `srcAccess`.len()))
298298
`forLoop`
299299
`count` = `srcAccess`.len()
300300

301301
proc emitOptionPack(dstAccess, srcAccess, userType: NimNode): NimNode =
302-
## Pack an Option into a `ptr`: some → `allocShared` box, none → nil. Payload
302+
## Pack an Option into a `ptr`: some → `cwireAllocBuf` box, none → nil. Payload
303303
## read into a local once so a composite inner type isn't re-`get()` per element.
304304
let innerType = userType[1]
305305
let wireInner = wireValueType(innerType)
@@ -308,7 +308,7 @@ proc emitOptionPack(dstAccess, srcAccess, userType: NimNode): NimNode =
308308
let elemPack = emitElemPack(nnkBracketExpr.newTree(dstAccess), innerVal, innerType)
309309
quote:
310310
if `srcAccess`.isSome():
311-
`dstAccess` = cast[`bufType`](allocShared(sizeof(`wireInner`)))
311+
`dstAccess` = cast[`bufType`](cwireAllocBuf(sizeof(`wireInner`)))
312312
let `innerVal` = `srcAccess`.get()
313313
`elemPack`
314314
else:
@@ -379,7 +379,7 @@ proc emitSeqFree(dstObj, fieldNameIdent, userType: NimNode): NimNode =
379379
quote:
380380
if not `items`.isNil():
381381
`freeLoop`
382-
deallocShared(`items`)
382+
cwireFreeBuf(`items`)
383383
`items` = nil
384384
`count` = 0
385385

@@ -390,7 +390,7 @@ proc emitOptionFree(dstAccess, userType: NimNode): NimNode =
390390
quote:
391391
if not `dstAccess`.isNil():
392392
`freeInner`
393-
deallocShared(`dstAccess`)
393+
cwireFreeBuf(`dstAccess`)
394394
`dstAccess` = nil
395395

396396
proc emitFreeStmt(dstObj, fieldNameIdent, userType: NimNode): seq[NimNode] =
@@ -539,6 +539,9 @@ type
539539
paramNames: seq[string] ## envelope field names (the extra params)
540540
paramTypes: seq[NimNode] ## envelope field types
541541
respType: NimNode ## method result T; empty for a ctor
542+
handler: NimNode
543+
## FFI-thread handler, deferred here so it lands after the `_CWire`
544+
## companions it packs/unpacks through.
542545

543546
var cAbiSpecs {.compileTime.}: seq[CAbiSpec]
544547

@@ -553,7 +556,7 @@ proc registerCAbiMethod*(
553556
libType, envelope: NimNode,
554557
paramNames: seq[string],
555558
paramTypes: seq[NimNode],
556-
respType: NimNode,
559+
respType, handler: NimNode,
557560
) {.compileTime.} =
558561
## Record an `abi = c` method for `flushCAbiDispatch`. Nodes are `copyNimTree`
559562
## frozen: reusing the Req section's originals (bound to `nnkSym`) would ICE.
@@ -566,6 +569,7 @@ proc registerCAbiMethod*(
566569
paramNames: paramNames,
567570
paramTypes: copyTypes(paramTypes),
568571
respType: respType.copyNimTree(),
572+
handler: handler.copyNimTree(),
569573
)
570574
)
571575

@@ -574,6 +578,7 @@ proc registerCAbiCtor*(
574578
libType, envelope: NimNode,
575579
paramNames: seq[string],
576580
paramTypes: seq[NimNode],
581+
handler: NimNode,
577582
) {.compileTime.} =
578583
## Record an `abi = c` ctor for `flushCAbiDispatch`; see `registerCAbiMethod`
579584
## for why nodes are `copyNimTree` frozen.
@@ -586,6 +591,7 @@ proc registerCAbiCtor*(
586591
paramNames: paramNames,
587592
paramTypes: copyTypes(paramTypes),
588593
respType: newEmptyNode(),
594+
handler: handler.copyNimTree(),
589595
)
590596
)
591597

@@ -630,15 +636,16 @@ proc replyTrampProc(trampName, body: NimNode): NimNode =
630636
pragmas = cdeclReplyPragma(),
631637
)
632638

633-
proc objectTrampBody(boxName, respType, respWire: NimNode): NimNode =
634-
## Reply trampoline for an object return: decode, `cwirePack` into `_CWire`,
635-
## hand a pointer to the caller, release. `reply` is nil only on error.
639+
proc objectTrampBody(boxName, respWire: NimNode): NimNode =
640+
## Reply trampoline for an object return: the payload is already the packed
641+
## `_CWire` image, so hand its address straight to the caller and release the
642+
## buffers it owns. `reply` is nil only on error.
636643
quote:
637644
let box = cast[ptr `boxName`](ud)
638645
if box.isNil():
639646
return
640647
if ret == RET_STALE_WARN:
641-
# Non-terminal progress signal: keep the box, don't decode.
648+
# Non-terminal progress signal: keep the box, don't read the payload.
642649
return
643650
defer:
644651
freeBox(box)
@@ -651,53 +658,46 @@ proc objectTrampBody(boxName, respType, respWire: NimNode): NimNode =
651658
copyMem(addr em[0], msg, int(len))
652659
box.fn(ret, nil, em.cstring, box.ud)
653660
return
654-
let decoded =
655-
cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), `respType`)
656-
if decoded.isErr():
657-
box.fn(RET_ERR, nil, decoded.error.cstring, box.ud)
658-
else:
659-
var wire: `respWire`
660-
cwirePack(wire, decoded.get())
661-
box.fn(RET_OK, addr wire, "".cstring, box.ud)
662-
cwireFree(wire)
661+
if msg.isNil() or int(len) != sizeof(`respWire`):
662+
box.fn(RET_ERR, nil, "abi = c reply: unexpected payload size".cstring, box.ud)
663+
return
664+
var wire = cast[ptr `respWire`](msg)[]
665+
box.fn(RET_OK, addr wire, "".cstring, box.ud)
666+
cwireFree(wire)
663667
except CatchableError as e:
664668
box.fn(RET_ERR, nil, e.msg.cstring, box.ud)
665669

666670
proc stringTrampBody(boxName: NimNode): NimNode =
667-
## Reply trampoline for a `string` return (and the ctor's address string):
668-
## decode and hand the caller a NUL-terminated `cstring`. Reply/error strings
669-
## are always non-nil empty on the paths they don't apply to (no nil deref).
671+
## Reply trampoline for a `string` return (and the ctor's address string): the
672+
## payload is raw UTF-8, copied into a NUL-terminated `cstring` since the wire
673+
## bytes aren't terminated. Reply/error strings are always non-nil empty on the
674+
## paths they don't apply to (no nil deref).
670675
quote:
671676
let box = cast[ptr `boxName`](ud)
672677
if box.isNil():
673678
return
674679
if ret == RET_STALE_WARN:
675-
# Non-terminal progress signal: keep the box, don't decode.
680+
# Non-terminal progress signal: keep the box, don't read the payload.
676681
return
677682
defer:
678683
freeBox(box)
679684
if box.fn.isNil():
680685
return
681686
try:
687+
var payload = newString(int(len))
688+
if int(len) > 0 and not msg.isNil():
689+
copyMem(addr payload[0], msg, int(len))
682690
if ret != RET_OK:
683-
var em = newString(int(len))
684-
if int(len) > 0:
685-
copyMem(addr em[0], msg, int(len))
686-
box.fn(ret, "".cstring, em.cstring, box.ud)
691+
box.fn(ret, "".cstring, payload.cstring, box.ud)
687692
return
688-
let decoded = cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), string)
689-
if decoded.isErr():
690-
box.fn(RET_ERR, "".cstring, decoded.error.cstring, box.ud)
691-
else:
692-
let replyStr = decoded.get()
693-
box.fn(RET_OK, replyStr.cstring, "".cstring, box.ud)
693+
box.fn(RET_OK, payload.cstring, "".cstring, box.ud)
694694
except CatchableError as e:
695695
box.fn(RET_ERR, "".cstring, e.msg.cstring, box.ud)
696696

697697
proc exportedMethodProc(
698698
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
699699
): NimNode =
700-
# No `foreignThreadGc`: `cwireUnpack`/`cborEncodeShared` alloc on the calling thread (already GC-registered); wrapping would free its live ORC heap.
700+
# No `foreignThreadGc`: `cwireUnpack`/`cwirePack` alloc on the calling thread (already GC-registered); wrapping would free its live ORC heap.
701701
let envName = spec.envelope
702702
let libFFICtx =
703703
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType))
@@ -713,23 +713,31 @@ proc exportedMethodProc(
713713
if not `poolIdent`.isValidCtx(cast[pointer](ctx)):
714714
onReply(RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData)
715715
return RET_ERR
716-
var reqObj: `envName` = cwireUnpack(req[])
717-
let enc = cborEncodeShared(reqObj)
718-
let reqBuf = enc.data
719-
let reqBufLen = enc.len
716+
var ownedWire: `envWire`
717+
cwirePack(ownedWire, cwireUnpack(req[]))
718+
let ownedCopy = cwireOwnedCopy(ownedWire)
719+
if ownedCopy.isNil():
720+
cwireFree(ownedWire)
721+
onReply(RET_ERR, `emptyReply`, "out of memory".cstring, userData)
722+
return RET_ERR
723+
let reqBuf = cast[ptr UncheckedArray[byte]](ownedCopy)
720724
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
721725
box.fn = onReply
722726
box.ud = userData
723727
let typeStr = $`envName`
724728
let reqPtr = FFIThreadRequest.initFromOwnedShared(
725-
`trampName`, box, typeStr.cstring, reqBuf, reqBufLen
729+
`trampName`, box, typeStr.cstring, reqBuf, sizeof(`envWire`), rawReply = true
726730
)
727731
let sendRes =
728732
try:
729733
ffi_context.sendRequestToFFIThread(ctx, reqPtr)
730734
except Exception as e:
731735
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
732736
if sendRes.isErr():
737+
# A rejected send already `deleteRequest`ed the struct copy, which frees only
738+
# the struct itself; `ownedWire` still aliases its field buffers, so free them
739+
# here — on success the FFI thread's unpack does it instead.
740+
cwireFree(ownedWire)
733741
onReply(RET_ERR, `emptyReply`, sendRes.error.cstring, userData)
734742
return RET_ERR
735743
return RET_OK
@@ -774,23 +782,31 @@ proc exportedCtorProc(
774782
)
775783
return nil
776784
let ctx = ctxRes.get()
777-
var reqObj: `envName` = cwireUnpack(req[])
778-
let enc = cborEncodeShared(reqObj)
779-
let reqBuf = enc.data
780-
let reqBufLen = enc.len
785+
var ownedWire: `envWire`
786+
cwirePack(ownedWire, cwireUnpack(req[]))
787+
let ownedCopy = cwireOwnedCopy(ownedWire)
788+
if ownedCopy.isNil():
789+
cwireFree(ownedWire)
790+
if not onCreated.isNil():
791+
onCreated(RET_ERR, "".cstring, "out of memory".cstring, userData)
792+
return nil
793+
let reqBuf = cast[ptr UncheckedArray[byte]](ownedCopy)
781794
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
782795
box.fn = onCreated
783796
box.ud = userData
784797
let typeStr = $`envName`
785798
let reqPtr = FFIThreadRequest.initFromOwnedShared(
786-
`trampName`, box, typeStr.cstring, reqBuf, reqBufLen
799+
`trampName`, box, typeStr.cstring, reqBuf, sizeof(`envWire`), rawReply = true
787800
)
788801
let sendRes =
789802
try:
790803
ctx.sendRequestToFFIThread(reqPtr)
791804
except Exception as e:
792805
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
793806
if sendRes.isErr():
807+
# See exportedMethodProc: the rejected send freed the struct copy, not the
808+
# field buffers `ownedWire` still aliases.
809+
cwireFree(ownedWire)
794810
if not onCreated.isNil():
795811
onCreated(RET_ERR, "".cstring, sendRes.error.cstring, userData)
796812
return nil
@@ -838,6 +854,7 @@ proc flushCAbiDispatch*(): NimNode {.compileTime.} =
838854
for spec in cAbiSpecs:
839855
let envName = spec.envelope
840856
ensureCWireForFields(sink, $envName, spec.paramNames, spec.paramTypes)
857+
sink.add(spec.handler)
841858
let envWire = ident(cwireTypeName($envName))
842859
let boxName = ident($envName & "CBox")
843860
let trampName = ident($envName & "CReply")
@@ -861,7 +878,7 @@ proc flushCAbiDispatch*(): NimNode {.compileTime.} =
861878
let respWire = ident(cwireTypeName($rt))
862879
let cbType = cAbiCbType(nnkPtrTy.newTree(respWire))
863880
sink.add(boxTypeDef(boxName, cbType))
864-
sink.add(replyTrampProc(trampName, objectTrampBody(boxName, rt, respWire)))
881+
sink.add(replyTrampProc(trampName, objectTrampBody(boxName, respWire)))
865882
sink.add(
866883
exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType)
867884
)

0 commit comments

Comments
 (0)