Skip to content

Commit c91287b

Browse files
authored
feat: CBOR-free scalar fast path for abi=c procs (#110)
1 parent fdc68ea commit c91287b

6 files changed

Lines changed: 128 additions & 33 deletions

File tree

ffi/codegen/meta.nim

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ type
3333
abiFormat*: ABIFormat # wire format for this interaction (default Cbor)
3434
scalarFastPath*: bool
3535
## True for an `abi = c` proc whose whole signature is scalar (see
36-
## `isScalarOnly`): it dispatches through the CBOR-free scalar fast path
37-
## and is skipped by the foreign-binding generators (no dispatch codegen
38-
## yet — the request rides inline POD args, no `_CWire`, no CBOR).
36+
## `isScalarOnly`): dispatches through the CBOR-free scalar fast path and
37+
## is skipped by the foreign-binding generators (their codegen is a
38+
## follow-up).
3939

4040
FFIFieldMeta* = object
4141
name*: string # e.g. "delayMs"

ffi/ffi_thread_request.nim

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,14 @@ type FFIThreadRequest* = object
3030
reqId*: cstring ## Per-proc Req type name used to look up the handler.
3131
data*: ptr UncheckedArray[byte] ## Owned CBOR-encoded request payload.
3232
dataLen*: int
33+
isScalar*: bool
34+
## Set by `initScalar`: the payload rode inline in `scalarArgs` (no CBOR,
35+
## no `data` buffer). Lets `handleRes` tell a scalar 0-length return (a real
36+
## empty string) from a CBOR "no value".
3337
scalarArgs*: array[MaxScalarArgs, uint64]
34-
## Scalar-fast-path args inlined in the envelope (one `ffiPackScalar` value
35-
## per slot) so there's no per-call `c_malloc`. A plain array rather than a
36-
## `union` with `data`: costs a fixed 64 bytes per request but keeps the
37-
## `next` link and `deleteRequest` unaliased and branch-free.
38+
## Scalar-fast-path args inlined in the envelope so there's no per-call
39+
## `c_malloc`. A plain array rather than a `union` with `data`: costs a fixed
40+
## 64 bytes per request but keeps `deleteRequest` unaliased and branch-free.
3841
next*: ptr FFIThreadRequest
3942
## Intrusive ingress-queue link (see `ffi_request_queue.nim`). Touched only
4043
## under the queue's lock; the request doubles as its own node, so no
@@ -79,6 +82,7 @@ proc allocBaseRequest(
7982
ret[].reqId = reqId.alloc()
8083
ret[].data = nil
8184
ret[].dataLen = 0
85+
ret[].isScalar = false
8286
ret[].next = nil
8387
ret[].responded = false
8488
return ret
@@ -169,17 +173,17 @@ proc initScalar*(
169173
"initScalar: " & $args.len & " scalar args exceed MaxScalarArgs (" & $MaxScalarArgs &
170174
")"
171175
var ret = allocBaseRequest(callback, userData, reqId)
176+
ret[].isScalar = true
172177
for i in 0 ..< args.len:
173178
ret[].scalarArgs[i] = args[i]
174179
ret
175180

176181
func ffiScalarRetBytes*[T](x: T): seq[byte] =
177-
## Serializes a scalar handler result into the raw response payload the
178-
## callback carries — no CBOR envelope. A `string`/`cstring` rides as its
179-
## own UTF-8 bytes (like the error path); every other scalar rides as the
180-
## 8-byte native-endian image of `ffiPackScalar(x)`. Note: an empty string
181-
## yields a 0-length payload, which `handleRes` sends as the CBOR-null
182-
## sentinel — the foreign scalar reader (a follow-up) must special-case it.
182+
## Serializes a scalar handler result into the raw response payload — no CBOR
183+
## envelope. A `string`/`cstring` rides as its own UTF-8 bytes (like the error
184+
## path); every other scalar rides as the 8-byte native-endian image of
185+
## `ffiPackScalar(x)`. An empty string yields a 0-length payload (see
186+
## `handleRes`, which delivers it as `""` rather than the CBOR-null sentinel).
183187
when T is string:
184188
var b = newSeq[byte](x.len)
185189
if x.len > 0:
@@ -231,6 +235,15 @@ proc fireCallback*(res: Result[seq[byte], string], request: ptr FFIThreadRequest
231235
cast[csize_t](bytes.len),
232236
request[].userData,
233237
)
238+
elif request[].isScalar:
239+
# `isScalar` marks a scalar-fast-path request (args rode inline in
240+
# `scalarArgs`, no CBOR): its result bytes come from `ffiScalarRetBytes`,
241+
# not a CBOR encoder. So a 0-byte return is a real empty string, not
242+
# "no value" — hand back a genuine empty buffer, not the CBOR-null sentinel.
243+
var empty: byte
244+
request[].callback(
245+
RET_OK, cast[ptr cchar](addr empty), 0.csize_t, request[].userData
246+
)
234247
else:
235248
# Always hand the callback a real buffer; CBOR null marks "no value".
236249
var sentinel = CborNullByte

ffi/internal/ffi_macro.nim

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -954,10 +954,9 @@ macro ffi*(args: varargs[untyped]): untyped =
954954
abiFormat: abiFormat,
955955
)
956956

957-
# Does this proc qualify for the CBOR-free scalar fast path? Only `abi = c`
958-
# opts in, and only when every wire param + the return is a plain scalar
959-
# (see `isScalarOnly`) and the args fit the inline slots. An `abi = c` proc
960-
# that isn't scalar-only stays gated — full C-wire dispatch is a follow-up.
957+
# Qualifies for the CBOR-free fast path only if `abi = c`, every wire param +
958+
# return is scalar (`isScalarOnly`), and the args fit the inline slots. A
959+
# non-scalar `abi = c` proc stays gated — full C-wire dispatch is a follow-up.
961960
let scalarEligible =
962961
abiFormat == ABIFormat.C and isScalarOnly(procMeta) and
963962
extraParamNames.len <= MaxScalarArgs
@@ -1821,6 +1820,12 @@ macro genBindings*(
18211820
# export doesn't take the CBOR `(reqCbor, reqCborLen)` shape); drop them so
18221821
# the generators don't emit a broken CBOR caller for them.
18231822
let genProcs = bindableProcs(ffiProcRegistry)
1823+
for p in ffiProcRegistry:
1824+
if p.scalarFastPath:
1825+
hint(
1826+
"genBindings: skipping scalar-fast-path proc '" & p.procName &
1827+
"' (no foreign-binding codegen for the scalar shape yet)"
1828+
)
18241829
case lang
18251830
of "rust":
18261831
generateRustCrate(
@@ -1832,8 +1837,7 @@ macro genBindings*(
18321837
)
18331838
of "c":
18341839
generateCBindings(
1835-
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
1836-
ffiEventRegistry,
1840+
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
18371841
)
18381842
of "cddl":
18391843
generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath)

ffi/internal/ffi_scalar.nim

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ const scalarPodTypeNames = [
1717
"int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32",
1818
"uint64", "byte", "float", "float32", "float64", "bool",
1919
]
20-
## Fixed-width POD scalars that fit a single `uint64` slot and survive the
21-
## async hop by value — the payload the scalar fast path inlines into the
22-
## request (no heap copy). `cstring`/`string` are intentionally absent as
23-
## *params*: they are pointers to caller memory the FFI thread reads later,
24-
## so they'd need a copy, defeating the zero-alloc promise.
20+
## Fixed-width POD scalars that fit one `uint64` slot and survive the async
21+
## hop by value. `cstring`/`string` are intentionally absent as *params*:
22+
## they point to caller memory the FFI thread reads after the call returns,
23+
## so passing them inline by value would be unsafe.
2524

2625
func isScalarParamTypeName*(name: string): bool =
2726
## A param type eligible for the CBOR-free scalar fast path.
@@ -37,7 +36,7 @@ func isScalarOnly*(p: FFIProcMeta): bool =
3736
## True iff `p` is a plain `{.ffi.}` method whose every wire param and return
3837
## is scalar — the whole signature crosses without CBOR or `_CWire`. Handles
3938
## and raw pointers are excluded (a handle needs a ctx-registry round-trip;
40-
## a pointer never crosses). Pure over the compile-time metadata.
39+
## a pointer never crosses).
4140
if p.kind != FFIKind.FFI:
4241
return false
4342
if p.returnIsPtr or p.returnIsHandle:
@@ -99,7 +98,7 @@ proc buildScalarPath*(
9998
let retValIdent = genSym(nskLet, "retVal")
10099
handlerBody.add quote do:
101100
let `retValIdent` = (await `helperCall`).valueOr:
102-
return err($error)
101+
return err(error)
103102
return ok(ffiScalarRetBytes(`retValIdent`))
104103

105104
let seqByteResult = nnkBracketExpr.newTree(

tests/unit/test_c_codegen.nim

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
## pipeline, no files written) and asserts on the emitted text — the same
44
## approach as test_cddl_codegen.
55

6-
import std/strutils
6+
import std/[strutils, sequtils]
77
import unittest2
88
import ffi/codegen/[meta, c]
9+
import ffi/internal/ffi_scalar
910

1011
proc field(n, t: string): FFIFieldMeta =
1112
FFIFieldMeta(name: n, typeName: t)
@@ -210,6 +211,60 @@ suite "generateCLibHeader: no-event libraries stay lean":
210211
check "listeners_len" notin header
211212
check "_add_event_listener" in header # raw ABI symbol is always declared
212213

214+
suite "generateCLibHeader: scalar-fast-path procs are excluded":
215+
setup:
216+
let procs = @[
217+
FFIProcMeta(
218+
procName: "calc_create",
219+
libName: "calc",
220+
kind: FFIKind.CTOR,
221+
libTypeName: "Calc",
222+
returnTypeName: "Calc",
223+
),
224+
FFIProcMeta(
225+
procName: "calc_echo",
226+
libName: "calc",
227+
kind: FFIKind.FFI,
228+
libTypeName: "Calc",
229+
extraParams: @[param("req", "EchoRequest")],
230+
returnTypeName: "EchoResponse",
231+
),
232+
FFIProcMeta(
233+
procName: "calc_add",
234+
libName: "calc",
235+
kind: FFIKind.FFI,
236+
libTypeName: "Calc",
237+
extraParams: @[param("a", "int"), param("b", "int")],
238+
returnTypeName: "int",
239+
abiFormat: ABIFormat.C,
240+
scalarFastPath: true,
241+
),
242+
]
243+
let types = @[
244+
FFITypeMeta(name: "EchoRequest", fields: @[field("m", "string")]),
245+
FFITypeMeta(name: "EchoResponse", fields: @[field("echoed", "string")]),
246+
]
247+
248+
test "bindableProcs keeps the CBOR procs and drops the scalar one":
249+
let kept = bindableProcs(procs)
250+
check kept.anyIt(it.procName == "calc_create")
251+
check kept.anyIt(it.procName == "calc_echo")
252+
check not kept.anyIt(it.procName == "calc_add")
253+
254+
test "the C header emitted from the bindable set carries no scalar symbol":
255+
let header = generateCLibHeader(bindableProcs(procs), types, "calc")
256+
check "int calc_echo(void* ctx, FFICallback callback" in header
257+
check "int calc_add(" notin header # note: calc_add_event_listener is unrelated
258+
259+
test "unfiltered, the generator would emit a wrong-ABI CBOR caller for it":
260+
# Regression guard for the bug bindableProcs prevents: handed the raw
261+
# registry, the C generator declares calc_add with the CBOR
262+
# (req_cbor, req_cbor_len) prototype, which mismatches its real inline-arg
263+
# export. genBindings must feed the filtered set (see the `of "c":` branch).
264+
let header = generateCLibHeader(procs, types, "calc")
265+
check "int calc_add(void* ctx, FFICallback callback, void* user_data, " &
266+
"const uint8_t* req_cbor, size_t req_cbor_len);" in header
267+
213268
suite "shared headers: prelude and cbor split":
214269
test "the prelude owns the leaf types and libc/TinyCBOR includes":
215270
let prelude = generateCPreludeHeader()

tests/unit/test_scalar_fastpath.nim

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ proc scalarfast_checked*(
6161
return err("negative not allowed")
6262
return ok(n * 2)
6363

64-
## --- C-shape callback harness (mirrors test_ffi_context.nim) ----------------
64+
proc scalarfast_blank*(
65+
lib: ScalarLib
66+
): Future[Result[string, string]] {.ffi: "abi = c".} =
67+
## Empty-string return — must ride back as a genuine 0-length RET_OK payload,
68+
## not the CBOR-null sentinel.
69+
return ok("")
70+
71+
## C-shape callback harness (mirrors test_ffi_context.nim).
6572

6673
type CallbackData = object
6774
lock: Lock
@@ -122,10 +129,7 @@ proc scalarStr(d: CallbackData): string =
122129
s
123130

124131
proc callbackErr(d: CallbackData): string =
125-
var msg = newString(d.msgLen)
126-
if d.msgLen > 0:
127-
copyMem(addr msg[0], unsafeAddr d.msg[0], d.msgLen)
128-
msg
132+
scalarStr(d)
129133

130134
proc encodedPtr(bytes: var seq[byte]): ptr byte =
131135
if bytes.len == 0:
@@ -183,6 +187,22 @@ suite "scalar fast path — C export shape":
183187
check d.retCode == RET_OK
184188
check scalarStr(d) == "scalarfast v1"
185189

190+
test "empty string return rides back as a real 0-length RET_OK payload":
191+
let ctx = makeCtx(0)
192+
defer:
193+
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
194+
195+
var d: CallbackData
196+
initCallbackData(d)
197+
defer:
198+
deinitCallbackData(d)
199+
200+
check scalarfast_blank(ctx, testCallback, addr d) == RET_OK
201+
waitCallback(d)
202+
check d.retCode == RET_OK
203+
check d.msgLen == 0 # NOT 1 (would be the 0xf6 sentinel)
204+
check scalarStr(d) == ""
205+
186206
test "float param round-trips through the uint64 slot":
187207
let ctx = makeCtx(0)
188208
defer:
@@ -250,13 +270,17 @@ suite "scalar fast path — Nim-native shape":
250270
check bad.isErr()
251271
check bad.error == "negative not allowed"
252272

273+
let blank = waitFor scalarfast_blank(ScalarLib(base: 0))
274+
check blank.isOk()
275+
check blank.value == ""
276+
253277
# `ffiProcRegistry` is a compile-time var, so its assertions run in a static
254278
# block (mirrors test_abi_format.nim). A scalar-only `abi = c` proc must be
255279
# flagged, recognised by `isScalarOnly`, and dropped from `bindableProcs`.
256280
static:
257281
const scalarNames = [
258282
"scalarfast_add", "scalarfast_version", "scalarfast_scale", "scalarfast_positive",
259-
"scalarfast_checked",
283+
"scalarfast_checked", "scalarfast_blank",
260284
]
261285
var seen = 0
262286
for p in ffiProcRegistry:

0 commit comments

Comments
 (0)