mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-07-16 21:10:19 +00:00
feat(ffi): CBOR-free scalar fast path for all-scalar abi = c procs (#106)
This commit is contained in:
parent
8c1343aaf2
commit
fb0a1f1077
@ -31,6 +31,11 @@ type
|
||||
returnIsPtr*: bool # true if return type is ptr T
|
||||
returnIsHandle*: bool # true if return type is an {.ffiHandle.} type
|
||||
abiFormat*: ABIFormat # wire format for this interaction (default Cbor)
|
||||
scalarFastPath*: bool
|
||||
## True for an `abi = c` proc whose whole signature is scalar (see
|
||||
## `isScalarOnly`): it dispatches through the CBOR-free scalar fast path
|
||||
## and is skipped by the foreign-binding generators (no dispatch codegen
|
||||
## yet — the request rides inline POD args, no `_CWire`, no CBOR).
|
||||
|
||||
FFIFieldMeta* = object
|
||||
name*: string # e.g. "delayMs"
|
||||
|
||||
@ -20,17 +20,50 @@ const EmptyErrorMarker = "unknown error"
|
||||
## the callback's msg ptr non-nil and gives the foreign side a recognizable
|
||||
## fallback to log.
|
||||
|
||||
const MaxScalarArgs* = 8
|
||||
## Inline capacity for the scalar fast path. A `.ffi.` method with more than
|
||||
## this many scalar params can't use the fast path (checked at compile time).
|
||||
|
||||
type FFIThreadRequest* = object
|
||||
callback*: FFICallBack
|
||||
userData*: pointer
|
||||
reqId*: cstring ## Per-proc Req type name used to look up the handler.
|
||||
data*: ptr UncheckedArray[byte] ## Owned CBOR-encoded request payload.
|
||||
dataLen*: int
|
||||
scalarArgs*: array[MaxScalarArgs, uint64]
|
||||
## Scalar-fast-path args inlined in the envelope (one `ffiPackScalar` value
|
||||
## per slot) so there's no per-call `c_malloc`. A plain array rather than a
|
||||
## `union` with `data`: costs a fixed 64 bytes per request but keeps the
|
||||
## `next` link and `deleteRequest` unaliased and branch-free.
|
||||
next*: ptr FFIThreadRequest
|
||||
## Intrusive ingress-queue link (see `ffi_request_queue.nim`). Touched only
|
||||
## under the queue's lock; the request doubles as its own node, so no
|
||||
## separate node alloc lands on the per-thread ORC MemRegion.
|
||||
|
||||
func ffiPackScalar*[T](x: T): uint64 =
|
||||
## Bit-cast one scalar into a `uint64` request slot. Signed ints sign-extend
|
||||
## to 64 bits; `float32` widens to `float64` (exactly representable, so the
|
||||
## value round-trips); `bool` becomes 0/1. Reverse with `ffiUnpackScalar`.
|
||||
when T is SomeFloat:
|
||||
cast[uint64](float64(x))
|
||||
elif T is bool:
|
||||
uint64(ord(x))
|
||||
elif T is SomeSignedInt:
|
||||
cast[uint64](int64(x))
|
||||
else:
|
||||
uint64(x)
|
||||
|
||||
func ffiUnpackScalar*[T](u: uint64, _: typedesc[T]): T =
|
||||
## Inverse of `ffiPackScalar`: reinterpret a request slot back into `T`.
|
||||
when T is SomeFloat:
|
||||
T(cast[float64](u))
|
||||
elif T is bool:
|
||||
u != 0'u64
|
||||
elif T is SomeSignedInt:
|
||||
T(cast[int64](u))
|
||||
else:
|
||||
T(u)
|
||||
|
||||
proc allocBaseRequest(
|
||||
callback: FFICallBack, userData: pointer, reqId: cstring
|
||||
): ptr FFIThreadRequest =
|
||||
@ -118,6 +151,48 @@ proc initFromOwnedShared*(
|
||||
adoptOwnedSharedPayload(ret, data, dataLen)
|
||||
return ret
|
||||
|
||||
proc initScalar*(
|
||||
T: typedesc[FFIThreadRequest],
|
||||
callback: FFICallBack,
|
||||
userData: pointer,
|
||||
reqId: cstring,
|
||||
args: varargs[uint64],
|
||||
): ptr type T =
|
||||
## Builds a scalar-fast-path request: the packed scalar args ride inline in
|
||||
## `scalarArgs` with no payload `c_malloc`. `args` come from `ffiPackScalar`.
|
||||
## Only the routing `reqId` cstring is heap-allocated, same as the CBOR path.
|
||||
doAssert args.len <= MaxScalarArgs,
|
||||
"initScalar: " & $args.len & " scalar args exceed MaxScalarArgs (" & $MaxScalarArgs &
|
||||
")"
|
||||
var ret = allocBaseRequest(callback, userData, reqId)
|
||||
for i in 0 ..< args.len:
|
||||
ret[].scalarArgs[i] = args[i]
|
||||
ret
|
||||
|
||||
func ffiScalarRetBytes*[T](x: T): seq[byte] =
|
||||
## Serializes a scalar handler result into the raw response payload the
|
||||
## callback carries — no CBOR envelope. A `string`/`cstring` rides as its
|
||||
## own UTF-8 bytes (like the error path); every other scalar rides as the
|
||||
## 8-byte native-endian image of `ffiPackScalar(x)`. Note: an empty string
|
||||
## yields a 0-length payload, which `handleRes` sends as the CBOR-null
|
||||
## sentinel — the foreign scalar reader (a follow-up) must special-case it.
|
||||
when T is string:
|
||||
var b = newSeq[byte](x.len)
|
||||
if x.len > 0:
|
||||
copyMem(addr b[0], unsafeAddr x[0], x.len)
|
||||
b
|
||||
elif T is cstring:
|
||||
let n = x.len
|
||||
var b = newSeq[byte](n)
|
||||
if n > 0:
|
||||
copyMem(addr b[0], cast[pointer](x), n)
|
||||
b
|
||||
else:
|
||||
let u = ffiPackScalar(x)
|
||||
var b = newSeq[byte](sizeof(uint64))
|
||||
copyMem(addr b[0], unsafeAddr u, sizeof(uint64))
|
||||
b
|
||||
|
||||
proc deleteRequest*(request: ptr FFIThreadRequest) =
|
||||
if not request[].data.isNil:
|
||||
c_free(request[].data)
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import std/[macros, tables, strutils]
|
||||
import chronos
|
||||
import ../ffi_types
|
||||
import ../ffi_thread_request
|
||||
import ../codegen/[meta, string_helpers]
|
||||
import ./c_macro_helpers
|
||||
import ./ffi_scalar
|
||||
when defined(ffiGenBindings):
|
||||
import ../codegen/rust
|
||||
import ../codegen/cpp
|
||||
@ -762,7 +764,6 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
return registerFFITypeInfo(cleanTypeDef, abiFormat)
|
||||
|
||||
requireLibraryDeclared("`.ffi.`")
|
||||
gateABIFormat(abiFormat, "`.ffi.` proc")
|
||||
|
||||
let procName = prc[0]
|
||||
let formalParams = prc[3]
|
||||
@ -841,6 +842,97 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
let ctxType =
|
||||
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName))
|
||||
|
||||
proc wireParamMeta(pname: string, ptype: NimNode): FFIParamMeta =
|
||||
let isPointer = isPtr(ptype)
|
||||
let handle = isHandleType(ptype)
|
||||
let tn =
|
||||
if isPointer:
|
||||
nimTypeNameRepr(ptype[0])
|
||||
else:
|
||||
nimTypeNameRepr(ptype)
|
||||
FFIParamMeta(name: pname, typeName: tn, isPtr: isPointer, isHandle: handle)
|
||||
|
||||
var wireParamMetas: seq[FFIParamMeta] = @[]
|
||||
for i in 0 ..< extraParamNames.len:
|
||||
wireParamMetas.add(wireParamMeta(extraParamNames[i], extraParamTypes[i]))
|
||||
|
||||
let retTypeInner = resultInner[1]
|
||||
let retIsPtr = isPtr(retTypeInner)
|
||||
let retIsHandle = isHandleType(retTypeInner)
|
||||
let retTn =
|
||||
if retIsPtr:
|
||||
nimTypeNameRepr(retTypeInner[0])
|
||||
else:
|
||||
nimTypeNameRepr(retTypeInner)
|
||||
|
||||
# Built once, registered by whichever path runs (only `scalarFastPath` differs
|
||||
# between them) and reused for the fast-path eligibility check below.
|
||||
let procMeta = FFIProcMeta(
|
||||
procName: cExportName,
|
||||
libName: currentLibName,
|
||||
kind: FFIKind.FFI,
|
||||
libTypeName: $libTypeName,
|
||||
extraParams: wireParamMetas,
|
||||
returnTypeName: retTn,
|
||||
returnIsPtr: retIsPtr,
|
||||
returnIsHandle: retIsHandle,
|
||||
abiFormat: abiFormat,
|
||||
)
|
||||
|
||||
# Does this proc qualify for the CBOR-free scalar fast path? Only `abi = c`
|
||||
# opts in, and only when every wire param + the return is a plain scalar
|
||||
# (see `isScalarOnly`) and the args fit the inline slots. An `abi = c` proc
|
||||
# that isn't scalar-only stays gated — full C-wire dispatch is a follow-up.
|
||||
let scalarEligible =
|
||||
abiFormat == ABIFormat.C and isScalarOnly(procMeta) and
|
||||
extraParamNames.len <= MaxScalarArgs
|
||||
|
||||
let poolIdent = ident($libTypeName & "FFIPool")
|
||||
|
||||
proc buildCtxGuard(): NimNode =
|
||||
## Nil-checks the callback and validates `ctx` against the lib's FFI pool,
|
||||
## replying `RET_ERR` before any request is built. Shared by both wire paths.
|
||||
quote:
|
||||
if callback.isNil:
|
||||
return RET_MISSING_CALLBACK
|
||||
if not `poolIdent`.isValidCtx(cast[pointer](ctx)):
|
||||
let errStr = "ctx is not a valid FFI context"
|
||||
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
|
||||
return RET_ERR
|
||||
|
||||
proc buildSendAndReply(reqPtrIdent: NimNode): NimNode =
|
||||
## Hands `reqPtrIdent` to the FFI thread and maps the outcome to a C return
|
||||
## code, reporting any enqueue failure through the callback. Shared by both
|
||||
## wire paths.
|
||||
let sendResIdent = genSym(nskLet, "sendRes")
|
||||
quote:
|
||||
let `sendResIdent` =
|
||||
try:
|
||||
ffi_context.sendRequestToFFIThread(ctx, `reqPtrIdent`)
|
||||
except Exception as exc:
|
||||
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
|
||||
if `sendResIdent`.isErr():
|
||||
let errStr = "error in sendRequestToFFIThread: " & `sendResIdent`.error
|
||||
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
|
||||
return RET_ERR
|
||||
return RET_OK
|
||||
|
||||
proc buildCExportProc(params: seq[NimNode], body: NimNode): NimNode =
|
||||
## The dynlib/exportc/cdecl C-ABI wrapper both wire paths emit; only the
|
||||
## params and body differ.
|
||||
newProc(
|
||||
name = postfix(cExportProcName, "*"),
|
||||
params = params,
|
||||
body = body,
|
||||
pragmas = newTree(
|
||||
nnkPragma,
|
||||
ident("dynlib"),
|
||||
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
|
||||
ident("cdecl"),
|
||||
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
|
||||
),
|
||||
)
|
||||
|
||||
proc buildAsyncHelperProc(): NimNode =
|
||||
## Reproduces the user's exact signature so it stays callable from Nim.
|
||||
var helperParams = newSeq[NimNode]()
|
||||
@ -904,17 +996,7 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
let exportedParams = cExportedParams(ctxType)
|
||||
|
||||
let ffiBody = newStmtList()
|
||||
|
||||
ffiBody.add quote do:
|
||||
if callback.isNil:
|
||||
return RET_MISSING_CALLBACK
|
||||
|
||||
let asyncPoolIdent = ident($libTypeName & "FFIPool")
|
||||
ffiBody.add quote do:
|
||||
if not `asyncPoolIdent`.isValidCtx(cast[pointer](ctx)):
|
||||
let errStr = "ctx is not a valid FFI context"
|
||||
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
|
||||
return RET_ERR
|
||||
ffiBody.add buildCtxGuard()
|
||||
|
||||
# Build the FFIThreadRequest payload directly from the incoming bytes.
|
||||
let reqPtrIdent = genSym(nskLet, "reqPtr")
|
||||
@ -923,74 +1005,42 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
let `reqPtrIdent` = FFIThreadRequest.initFromPtr(
|
||||
callback, userData, typeStr.cstring, reqCbor, int(reqCborLen)
|
||||
)
|
||||
ffiBody.add buildSendAndReply(reqPtrIdent)
|
||||
|
||||
let sendResIdent = genSym(nskLet, "sendRes")
|
||||
ffiBody.add quote do:
|
||||
let `sendResIdent` =
|
||||
try:
|
||||
ffi_context.sendRequestToFFIThread(ctx, `reqPtrIdent`)
|
||||
except Exception as exc:
|
||||
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
|
||||
if `sendResIdent`.isErr():
|
||||
let errStr = "error in sendRequestToFFIThread: " & `sendResIdent`.error
|
||||
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
|
||||
return RET_ERR
|
||||
return RET_OK
|
||||
let ffiProc = buildCExportProc(exportedParams, ffiBody)
|
||||
|
||||
let ffiProc = newProc(
|
||||
name = postfix(cExportProcName, "*"),
|
||||
params = exportedParams,
|
||||
body = ffiBody,
|
||||
pragmas = newTree(
|
||||
nnkPragma,
|
||||
ident("dynlib"),
|
||||
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
|
||||
ident("cdecl"),
|
||||
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
|
||||
),
|
||||
)
|
||||
|
||||
block:
|
||||
var ffiExtraParams: seq[FFIParamMeta] = @[]
|
||||
for i in 0 ..< extraParamNames.len:
|
||||
let ptype = extraParamTypes[i]
|
||||
let isPointer = isPtr(ptype)
|
||||
let handle = isHandleType(ptype)
|
||||
let tn =
|
||||
if isPointer:
|
||||
nimTypeNameRepr(ptype[0])
|
||||
else:
|
||||
nimTypeNameRepr(ptype)
|
||||
ffiExtraParams.add(
|
||||
FFIParamMeta(
|
||||
name: extraParamNames[i], typeName: tn, isPtr: isPointer, isHandle: handle
|
||||
)
|
||||
)
|
||||
let retTypeInner = resultInner[1]
|
||||
let retIsPtr = isPtr(retTypeInner)
|
||||
let retIsHandle = isHandleType(retTypeInner)
|
||||
let retTn =
|
||||
if retIsPtr:
|
||||
nimTypeNameRepr(retTypeInner[0])
|
||||
else:
|
||||
nimTypeNameRepr(retTypeInner)
|
||||
ffiProcRegistry.add(
|
||||
FFIProcMeta(
|
||||
procName: cExportName,
|
||||
libName: currentLibName,
|
||||
kind: FFIKind.FFI,
|
||||
libTypeName: $libTypeName,
|
||||
extraParams: ffiExtraParams,
|
||||
returnTypeName: retTn,
|
||||
returnIsPtr: retIsPtr,
|
||||
returnIsHandle: retIsHandle,
|
||||
abiFormat: abiFormat,
|
||||
)
|
||||
)
|
||||
ffiProcRegistry.add(procMeta)
|
||||
|
||||
return newStmtList(helperProc, registerReq, ffiProc)
|
||||
|
||||
let stmts = asyncPath()
|
||||
proc scalarPath(): NimNode =
|
||||
## The scalar fast path lives in `ffi_scalar`; here we only build the shared
|
||||
## dispatch pieces (same helpers the usual path uses) and hand them over, so
|
||||
## the base macro carries none of the inline pack/unpack machinery.
|
||||
let reqPtrIdent = genSym(nskLet, "reqPtr")
|
||||
buildScalarPath(
|
||||
helperProc = buildAsyncHelperProc(),
|
||||
ctxGuard = buildCtxGuard(),
|
||||
reqPtrIdent = reqPtrIdent,
|
||||
sendAndReply = buildSendAndReply(reqPtrIdent),
|
||||
userProcName = userProcName,
|
||||
cExportProcName = cExportProcName,
|
||||
cExportName = cExportName,
|
||||
ctxType = ctxType,
|
||||
camelName = camelName,
|
||||
extraParamNames = extraParamNames,
|
||||
extraParamTypes = extraParamTypes,
|
||||
procMeta = procMeta,
|
||||
)
|
||||
|
||||
if abiFormat == ABIFormat.C and not scalarEligible:
|
||||
gateABIFormat(abiFormat, "`.ffi.` proc")
|
||||
|
||||
let stmts =
|
||||
if scalarEligible:
|
||||
scalarPath()
|
||||
else:
|
||||
asyncPath()
|
||||
|
||||
when defined(ffiDumpMacros):
|
||||
echo stmts.repr
|
||||
@ -1677,16 +1727,18 @@ macro genBindings*(
|
||||
)
|
||||
let lang = string_helpers.toLower(targetLang)
|
||||
let libName = deriveLibName(ffiProcRegistry)
|
||||
# Scalar-fast-path procs have no foreign-dispatch codegen yet (their C
|
||||
# export doesn't take the CBOR `(reqCbor, reqCborLen)` shape); drop them so
|
||||
# the generators don't emit a broken CBOR caller for them.
|
||||
let genProcs = bindableProcs(ffiProcRegistry)
|
||||
case lang
|
||||
of "rust":
|
||||
generateRustCrate(
|
||||
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
|
||||
ffiEventRegistry,
|
||||
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
|
||||
)
|
||||
of "cpp", "c++":
|
||||
generateCppBindings(
|
||||
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
|
||||
ffiEventRegistry,
|
||||
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
|
||||
)
|
||||
of "c":
|
||||
generateCBindings(
|
||||
@ -1694,9 +1746,7 @@ macro genBindings*(
|
||||
ffiEventRegistry,
|
||||
)
|
||||
of "cddl":
|
||||
generateCddlBindings(
|
||||
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath
|
||||
)
|
||||
generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath)
|
||||
else:
|
||||
error(
|
||||
"genBindings: unknown targetLang '" & lang &
|
||||
|
||||
172
ffi/internal/ffi_scalar.nim
Normal file
172
ffi/internal/ffi_scalar.nim
Normal file
@ -0,0 +1,172 @@
|
||||
## CBOR-free scalar fast path for all-scalar `{.ffi: "abi = c".}` methods.
|
||||
##
|
||||
## Kept out of the base `ffi` macro so the usual CBOR/async dispatch path in
|
||||
## `ffi_macro.nim` stays simple: the macro only decides eligibility
|
||||
## (`isScalarOnly`) and, when it applies, hands the whole codegen to
|
||||
## `buildScalarPath`.
|
||||
##
|
||||
## A scalar proc's C export takes its scalar args directly (no
|
||||
## `reqCbor`/`reqCborLen`), packs them inline into the request (no envelope
|
||||
## `c_malloc`, no CBOR), and the FFI-thread handler unpacks them, runs the user
|
||||
## body, and returns the result as raw bytes (`ffiScalarRetBytes`).
|
||||
|
||||
import std/macros
|
||||
import ../codegen/meta
|
||||
|
||||
const scalarPodTypeNames = [
|
||||
"int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32",
|
||||
"uint64", "byte", "float", "float32", "float64", "bool",
|
||||
]
|
||||
## Fixed-width POD scalars that fit a single `uint64` slot and survive the
|
||||
## async hop by value — the payload the scalar fast path inlines into the
|
||||
## request (no heap copy). `cstring`/`string` are intentionally absent as
|
||||
## *params*: they are pointers to caller memory the FFI thread reads later,
|
||||
## so they'd need a copy, defeating the zero-alloc promise.
|
||||
|
||||
func isScalarParamTypeName*(name: string): bool =
|
||||
## A param type eligible for the CBOR-free scalar fast path.
|
||||
name in scalarPodTypeNames
|
||||
|
||||
func isScalarReturnTypeName*(name: string): bool =
|
||||
## A return type eligible for the scalar fast path. Unlike params, a
|
||||
## `string`/`cstring` return is fine: the handler produces the bytes and they
|
||||
## ride back raw (like the error path), so no caller memory is aliased.
|
||||
name in scalarPodTypeNames or name == "string" or name == "cstring"
|
||||
|
||||
func isScalarOnly*(p: FFIProcMeta): bool =
|
||||
## True iff `p` is a plain `{.ffi.}` method whose every wire param and return
|
||||
## is scalar — the whole signature crosses without CBOR or `_CWire`. Handles
|
||||
## and raw pointers are excluded (a handle needs a ctx-registry round-trip;
|
||||
## a pointer never crosses). Pure over the compile-time metadata.
|
||||
if p.kind != FFIKind.FFI:
|
||||
return false
|
||||
if p.returnIsPtr or p.returnIsHandle:
|
||||
return false
|
||||
if not isScalarReturnTypeName(p.returnTypeName):
|
||||
return false
|
||||
for ep in p.extraParams:
|
||||
if ep.isPtr or ep.isHandle or not isScalarParamTypeName(ep.typeName):
|
||||
return false
|
||||
true
|
||||
|
||||
func bindableProcs*(procs: seq[FFIProcMeta]): seq[FFIProcMeta] =
|
||||
## The procs the foreign-binding generators emit for. Scalar-fast-path procs
|
||||
## are dropped: their C export takes inline scalar args, not the CBOR
|
||||
## `(reqCbor, reqCborLen)` shape the current codegen assumes, so emitting a
|
||||
## CBOR caller for them would be wrong. Foreign codegen is a follow-up.
|
||||
var kept: seq[FFIProcMeta] = @[]
|
||||
for p in procs:
|
||||
if not p.scalarFastPath:
|
||||
kept.add(p)
|
||||
kept
|
||||
|
||||
proc buildScalarPath*(
|
||||
helperProc, ctxGuard, reqPtrIdent, sendAndReply: NimNode,
|
||||
userProcName, cExportProcName: NimNode,
|
||||
cExportName: string,
|
||||
ctxType: NimNode,
|
||||
camelName: string,
|
||||
extraParamNames: seq[string],
|
||||
extraParamTypes: seq[NimNode],
|
||||
procMeta: FFIProcMeta,
|
||||
): NimNode {.compileTime.} =
|
||||
## Emits the scalar-fast-path codegen for one `.ffi.` proc. The generic
|
||||
## dispatch pieces (`helperProc`, `ctxGuard`, `sendAndReply`) are built by the
|
||||
## caller from the same shared helpers the usual path uses, so this only owns
|
||||
## the scalar-specific inline pack / unpack / raw-bytes wiring.
|
||||
let scalarReqKey = camelName & "Req"
|
||||
|
||||
let reqIdent = genSym(nskLet, "ffiReq")
|
||||
let ctxHandlerName = genSym(nskLet, "ffiCtxHandler")
|
||||
let handlerBody = newStmtList()
|
||||
handlerBody.add quote do:
|
||||
let `reqIdent` = cast[ptr FFIThreadRequest](request)
|
||||
let `ctxHandlerName` = cast[`ctxType`](reqHandler)
|
||||
|
||||
let helperCall = newTree(nnkCall, userProcName)
|
||||
let ctxMyLib = newDotExpr(newTree(nnkDerefExpr, ctxHandlerName), ident("myLib"))
|
||||
helperCall.add(newTree(nnkDerefExpr, ctxMyLib))
|
||||
for i in 0 ..< extraParamNames.len:
|
||||
let argIdent = ident(extraParamNames[i])
|
||||
let slot = nnkBracketExpr.newTree(
|
||||
newDotExpr(newTree(nnkDerefExpr, reqIdent), ident("scalarArgs")), newLit(i)
|
||||
)
|
||||
handlerBody.add(
|
||||
newLetStmt(argIdent, newCall(ident("ffiUnpackScalar"), slot, extraParamTypes[i]))
|
||||
)
|
||||
helperCall.add(argIdent)
|
||||
|
||||
let retValIdent = genSym(nskLet, "retVal")
|
||||
handlerBody.add quote do:
|
||||
let `retValIdent` = (await `helperCall`).valueOr:
|
||||
return err($error)
|
||||
return ok(ffiScalarRetBytes(`retValIdent`))
|
||||
|
||||
let seqByteResult = nnkBracketExpr.newTree(
|
||||
ident("Future"),
|
||||
nnkBracketExpr.newTree(
|
||||
ident("Result"),
|
||||
nnkBracketExpr.newTree(ident("seq"), ident("byte")),
|
||||
ident("string"),
|
||||
),
|
||||
)
|
||||
let handlerProc = newProc(
|
||||
name = newEmptyNode(),
|
||||
params = @[
|
||||
seqByteResult,
|
||||
newIdentDefs(ident("request"), ident("pointer")),
|
||||
newIdentDefs(ident("reqHandler"), ident("pointer")),
|
||||
],
|
||||
body = handlerBody,
|
||||
pragmas = nnkPragma.newTree(ident("async")),
|
||||
)
|
||||
let registerAssign = newAssignment(
|
||||
nnkBracketExpr.newTree(ident("registeredRequests"), newLit(scalarReqKey)),
|
||||
handlerProc,
|
||||
)
|
||||
|
||||
var scalarParams = @[
|
||||
ident("cint"),
|
||||
newIdentDefs(ident("ctx"), ctxType),
|
||||
newIdentDefs(ident("callback"), ident("FFICallBack")),
|
||||
newIdentDefs(ident("userData"), ident("pointer")),
|
||||
]
|
||||
for i in 0 ..< extraParamNames.len:
|
||||
scalarParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i]))
|
||||
|
||||
let ffiBody = newStmtList()
|
||||
ffiBody.add ctxGuard
|
||||
|
||||
let initScalarCall = newTree(
|
||||
nnkCall,
|
||||
newDotExpr(ident("FFIThreadRequest"), ident("initScalar")),
|
||||
ident("callback"),
|
||||
ident("userData"),
|
||||
newDotExpr(newLit(scalarReqKey), ident("cstring")),
|
||||
)
|
||||
for i in 0 ..< extraParamNames.len:
|
||||
initScalarCall.add(newCall(ident("ffiPackScalar"), ident(extraParamNames[i])))
|
||||
|
||||
ffiBody.add newLetStmt(reqPtrIdent, initScalarCall)
|
||||
ffiBody.add sendAndReply
|
||||
|
||||
let ffiProc = newProc(
|
||||
name = postfix(cExportProcName, "*"),
|
||||
params = scalarParams,
|
||||
body = ffiBody,
|
||||
pragmas = newTree(
|
||||
nnkPragma,
|
||||
ident("dynlib"),
|
||||
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
|
||||
ident("cdecl"),
|
||||
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
|
||||
),
|
||||
)
|
||||
|
||||
# Registered (not just skipped) so the compile-time metadata stays
|
||||
# introspectable; `bindableProcs` drops it from foreign codegen.
|
||||
var scalarMeta = procMeta
|
||||
scalarMeta.scalarFastPath = true
|
||||
ffiProcRegistry.add(scalarMeta)
|
||||
|
||||
newStmtList(helperProc, registerAssign, ffiProc)
|
||||
271
tests/unit/test_scalar_fastpath.nim
Normal file
271
tests/unit/test_scalar_fastpath.nim
Normal file
@ -0,0 +1,271 @@
|
||||
## Exercises the CBOR-free scalar fast path (`{.ffi: "abi = c".}` on an
|
||||
## all-scalar signature). A scalar proc's C export takes its scalar args
|
||||
## directly (no `reqCbor`/`reqCborLen`), packs them inline into the request,
|
||||
## and the response rides back as raw bytes — no CBOR envelope either way.
|
||||
##
|
||||
## Two angles are covered: the C-export shape (ctx, callback, userData, args…)
|
||||
## driven through a real FFI thread, and the Nim-native shape (the user proc
|
||||
## name still resolves to its declared `Future[Result[T, string]]`).
|
||||
|
||||
import std/[locks, strutils, sequtils]
|
||||
import unittest2
|
||||
import results
|
||||
import ffi
|
||||
import ffi/codegen/meta
|
||||
import ffi/internal/ffi_scalar
|
||||
|
||||
type ScalarLib = object
|
||||
base: int
|
||||
|
||||
# Stub the dylib NimMain importc that declareLibrary emits (this links as an exe).
|
||||
{.emit: "void libscalarfastNimMain(void) {}".}
|
||||
|
||||
declareLibrary("scalarfast", ScalarLib)
|
||||
|
||||
type ScalarConfig {.ffi.} = object
|
||||
base: int
|
||||
|
||||
proc scalarfast_create*(
|
||||
cfg: ScalarConfig
|
||||
): Future[Result[ScalarLib, string]] {.ffiCtor.} =
|
||||
return ok(ScalarLib(base: cfg.base))
|
||||
|
||||
proc scalarfast_add*(
|
||||
lib: ScalarLib, a: int, b: int
|
||||
): Future[Result[int, string]] {.ffi: "abi = c".} =
|
||||
## Two scalar params, scalar return — the flagship fast-path shape.
|
||||
return ok(lib.base + a + b)
|
||||
|
||||
proc scalarfast_version*(
|
||||
lib: ScalarLib
|
||||
): Future[Result[string, string]] {.ffi: "abi = c".} =
|
||||
## No params, string return (rides back as raw UTF-8, no CBOR).
|
||||
return ok("scalarfast v1")
|
||||
|
||||
proc scalarfast_scale*(
|
||||
lib: ScalarLib, factor: float
|
||||
): Future[Result[float, string]] {.ffi: "abi = c".} =
|
||||
## Float round-trip through the uint64 slot.
|
||||
return ok(factor * 2.0)
|
||||
|
||||
proc scalarfast_positive*(
|
||||
lib: ScalarLib, n: int
|
||||
): Future[Result[bool, string]] {.ffi: "abi = c".} =
|
||||
return ok(n > 0)
|
||||
|
||||
proc scalarfast_checked*(
|
||||
lib: ScalarLib, n: int
|
||||
): Future[Result[int, string]] {.ffi: "abi = c".} =
|
||||
## Error path — a scalar proc that can fail surfaces Result.err.
|
||||
if n < 0:
|
||||
return err("negative not allowed")
|
||||
return ok(n * 2)
|
||||
|
||||
## --- C-shape callback harness (mirrors test_ffi_context.nim) ----------------
|
||||
|
||||
type CallbackData = object
|
||||
lock: Lock
|
||||
cond: Cond
|
||||
called: bool
|
||||
retCode: cint
|
||||
msg: array[1024, byte]
|
||||
msgLen: int
|
||||
|
||||
proc initCallbackData(d: var CallbackData) =
|
||||
d.lock.initLock()
|
||||
d.cond.initCond()
|
||||
|
||||
proc deinitCallbackData(d: var CallbackData) =
|
||||
d.cond.deinitCond()
|
||||
d.lock.deinitLock()
|
||||
|
||||
proc testCallback(
|
||||
retCode: cint, msg: ptr cchar, len: csize_t, userData: pointer
|
||||
) {.cdecl, gcsafe, raises: [].} =
|
||||
let d = cast[ptr CallbackData](userData)
|
||||
acquire(d[].lock)
|
||||
d[].retCode = retCode
|
||||
let n = min(int(len), d[].msg.len)
|
||||
if n > 0 and not msg.isNil:
|
||||
copyMem(addr d[].msg[0], msg, n)
|
||||
d[].msgLen = n
|
||||
d[].called = true
|
||||
signal(d[].cond)
|
||||
release(d[].lock)
|
||||
|
||||
proc waitCallback(d: var CallbackData) =
|
||||
acquire(d.lock)
|
||||
while not d.called:
|
||||
wait(d.cond, d.lock)
|
||||
release(d.lock)
|
||||
|
||||
proc scalarU64(d: CallbackData): uint64 =
|
||||
## Reads the 8-byte native-endian POD image the fast path returns.
|
||||
doAssert d.msgLen == sizeof(uint64), "expected 8 scalar bytes, got " & $d.msgLen
|
||||
var u: uint64
|
||||
copyMem(addr u, unsafeAddr d.msg[0], sizeof(uint64))
|
||||
u
|
||||
|
||||
proc scalarInt(d: CallbackData): int =
|
||||
int(cast[int64](scalarU64(d)))
|
||||
|
||||
proc scalarFloat(d: CallbackData): float =
|
||||
cast[float64](scalarU64(d))
|
||||
|
||||
proc scalarBool(d: CallbackData): bool =
|
||||
scalarU64(d) != 0'u64
|
||||
|
||||
proc scalarStr(d: CallbackData): string =
|
||||
var s = newString(d.msgLen)
|
||||
if d.msgLen > 0:
|
||||
copyMem(addr s[0], unsafeAddr d.msg[0], d.msgLen)
|
||||
s
|
||||
|
||||
proc callbackErr(d: CallbackData): string =
|
||||
var msg = newString(d.msgLen)
|
||||
if d.msgLen > 0:
|
||||
copyMem(addr msg[0], unsafeAddr d.msg[0], d.msgLen)
|
||||
msg
|
||||
|
||||
proc encodedPtr(bytes: var seq[byte]): ptr byte =
|
||||
if bytes.len == 0:
|
||||
nil
|
||||
else:
|
||||
cast[ptr byte](addr bytes[0])
|
||||
|
||||
proc callbackBytesOf(d: CallbackData): seq[byte] =
|
||||
var bytes = newSeq[byte](d.msgLen)
|
||||
if d.msgLen > 0:
|
||||
copyMem(addr bytes[0], unsafeAddr d.msg[0], d.msgLen)
|
||||
bytes
|
||||
|
||||
proc makeCtx(base: int): ptr FFIContext[ScalarLib] =
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
var cfg = cborEncode(ScalarfastCreateCtorReq(cfg: ScalarConfig(base: base)))
|
||||
let ret = scalarfast_create(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr d)
|
||||
doAssert not ret.isNil()
|
||||
waitCallback(d)
|
||||
doAssert d.retCode == RET_OK
|
||||
let addrStr = cborDecode(callbackBytesOf(d), string).value
|
||||
cast[ptr FFIContext[ScalarLib]](cast[uint](parseBiggestUInt(addrStr)))
|
||||
|
||||
suite "scalar fast path — C export shape":
|
||||
test "two int params + int return, threads through the base state":
|
||||
let ctx = makeCtx(100)
|
||||
defer:
|
||||
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
|
||||
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
|
||||
check scalarfast_add(ctx, testCallback, addr d, 20, 3) == RET_OK
|
||||
waitCallback(d)
|
||||
check d.retCode == RET_OK
|
||||
check scalarInt(d) == 123
|
||||
|
||||
test "no params, string return rides back as raw UTF-8":
|
||||
let ctx = makeCtx(0)
|
||||
defer:
|
||||
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
|
||||
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
|
||||
check scalarfast_version(ctx, testCallback, addr d) == RET_OK
|
||||
waitCallback(d)
|
||||
check d.retCode == RET_OK
|
||||
check scalarStr(d) == "scalarfast v1"
|
||||
|
||||
test "float param round-trips through the uint64 slot":
|
||||
let ctx = makeCtx(0)
|
||||
defer:
|
||||
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
|
||||
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
|
||||
check scalarfast_scale(ctx, testCallback, addr d, 2.5) == RET_OK
|
||||
waitCallback(d)
|
||||
check d.retCode == RET_OK
|
||||
check scalarFloat(d) == 5.0
|
||||
|
||||
test "bool return":
|
||||
let ctx = makeCtx(0)
|
||||
defer:
|
||||
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
|
||||
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
|
||||
check scalarfast_positive(ctx, testCallback, addr d, -4) == RET_OK
|
||||
waitCallback(d)
|
||||
check d.retCode == RET_OK
|
||||
check scalarBool(d) == false
|
||||
|
||||
test "error result surfaces as RET_ERR with a raw UTF-8 message":
|
||||
let ctx = makeCtx(0)
|
||||
defer:
|
||||
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
|
||||
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
|
||||
check scalarfast_checked(ctx, testCallback, addr d, -1) == RET_OK
|
||||
waitCallback(d)
|
||||
check d.retCode == RET_ERR
|
||||
check callbackErr(d) == "negative not allowed"
|
||||
|
||||
test "invalid ctx is rejected before enqueue":
|
||||
var d: CallbackData
|
||||
initCallbackData(d)
|
||||
defer:
|
||||
deinitCallbackData(d)
|
||||
let bogus = cast[ptr FFIContext[ScalarLib]](nil)
|
||||
check scalarfast_add(bogus, testCallback, addr d, 1, 2) == RET_ERR
|
||||
|
||||
suite "scalar fast path — Nim-native shape":
|
||||
test "user proc name keeps its Future[Result[T, string]] signature":
|
||||
let add = waitFor scalarfast_add(ScalarLib(base: 1), 2, 3)
|
||||
check add.isOk()
|
||||
check add.value == 6
|
||||
|
||||
let ver = waitFor scalarfast_version(ScalarLib(base: 0))
|
||||
check ver.isOk()
|
||||
check ver.value == "scalarfast v1"
|
||||
|
||||
let bad = waitFor scalarfast_checked(ScalarLib(base: 0), -5)
|
||||
check bad.isErr()
|
||||
check bad.error == "negative not allowed"
|
||||
|
||||
# `ffiProcRegistry` is a compile-time var, so its assertions run in a static
|
||||
# block (mirrors test_abi_format.nim). A scalar-only `abi = c` proc must be
|
||||
# flagged, recognised by `isScalarOnly`, and dropped from `bindableProcs`.
|
||||
static:
|
||||
const scalarNames = [
|
||||
"scalarfast_add", "scalarfast_version", "scalarfast_scale", "scalarfast_positive",
|
||||
"scalarfast_checked",
|
||||
]
|
||||
var seen = 0
|
||||
for p in ffiProcRegistry:
|
||||
if p.procName in scalarNames:
|
||||
doAssert p.scalarFastPath, p.procName & " should be scalarFastPath"
|
||||
doAssert p.abiFormat == ABIFormat.C
|
||||
doAssert isScalarOnly(p), p.procName & " should be isScalarOnly"
|
||||
inc seen
|
||||
doAssert seen == scalarNames.len, "saw " & $seen & " scalar procs"
|
||||
# The ctor stays on the CBOR path and remains bindable.
|
||||
doAssert bindableProcs(ffiProcRegistry).anyIt(it.procName == "scalarfast_create")
|
||||
doAssert not bindableProcs(ffiProcRegistry).anyIt(it.scalarFastPath)
|
||||
Loading…
x
Reference in New Issue
Block a user