ds4-winhip / docs /rust-migration /FFI_CONTRACT.md
staccs's picture
ds4-winhip: Windows/HIP 7.2 build tree, launcher, supervisor, bench + notes (part 6)
5396c20 verified
|
Raw History Blame Contribute Delete
14.9 kB
# FFI contract
Rust talks to the native inference runtime through **one** header:
[`native/bridge/ds4_bridge.h`](../../native/bridge/ds4_bridge.h).
NVTX is host observability: `ds4-cli` uses the optional official NVIDIA Rust
SDK directly. Profiler symbols and handles do not belong in this ABI,
`ds4-sys`, or `ds4-core`. The optional `ds4-perf-gpu` helper isolates
profiling-only CUDA/CUPTI calls and callbacks behind maintained cudarc bindings.
That adapter is independent of this inference ABI; see the
[profiling contract](../ds4-perf.md).
```text
Rust application
↓
ds4-core (safe)
↓
ds4-sys (unsafe)
↓
ds4_bridge.h ← the source-matched inference ABI
↓
ds4_bridge.c
↓
existing ds4.h / ds4.c / ds4_cuda.cu internals
```
## Hard rules
1. Do **not** bindgen `ds4.h`, `ds4_gpu.h`, or `cuda/mmq/*.h`.
2. Keep native engine/device structures opaque. Only the documented
host-owned metadata descriptors and scalar/POD options cross by layout.
3. Do **not** put `CUstream`, device pointers, MMQ descriptors, or
graph execs in Rust application code.
4. `unsafe` belongs in `ds4-sys` or a tiny reviewed native/OS adapter.
Application policy/runtime code showing `unsafe {` is an architecture
defect; localized POSIX/linenoise adapters must carry a `SAFETY` rationale.
5. Errors cross the boundary as `int` + caller-provided `char *err`
buffer, matching the existing C session helpers. No C++ exceptions.
No Rust panic across FFI.
6. Call-scoped strings and token buffers are borrowed until return. The
bridge must copy retained data or document a longer borrow backed by a
live Rust owner (for example, model vocabulary strings).
7. Every successful `*_create` / `*_open` has exactly one `*_free`.
Rust `Drop` is the only application-side destructor.
## Opaque handles and safe ownership
`ds4_bridge_model`, `ds4_bridge_session`, snapshots and batch contexts are
opaque handles. The [safe wrappers](../../crates/ds4-core/src/lib.rs) own them
through `NonNull` and matching `Drop` implementations. `Session<'m>` borrows
its `Model`; snapshots and batches preserve the same model lifetime. Native
execution handles are thread-affine, not transferable application state.
Do not typedef these handles to `ds4_engine` / `ds4_session`. The bridge
structs may contain those pointers; Rust callers must not inspect them.
## Selected ABI operations
The header is the authoritative symbol list. This table explains contracts
used by the host and retained C parity helpers; it is not an export-count
freeze or permission to bind native internals.
| Function | Meaning |
|---|---|
| `ds4_bridge_bind_plan_check` | host inventory + required-name table; 0 if native can consume it |
| `ds4_bridge_bind_plan_match` | host vs native slot identity (name/need/found/type/dims/offsets/bytes/shard + remap) |
| `ds4_bridge_model_open` | mmap-backed `ds4_engine_open`; optional `opt->plan` is checked first; optional `opt->tensors` replaces `parse_tensors` (owned name copies); optional `opt->shape` applies the pinned C literal + DeepSeek compress table and skips `config_validate_model`; optional `opt->vocab` applies host token/merge/specials and skips `vocab_load`; optional `opt->bind` is the host name→tensor-dir index so `model_find_tensor` skips the C name walk and the main-model `weights_validate_layout` |
| `ds4_bridge_model_boot_prewarm` | idempotent `ds4_engine_boot_prewarm`; server calls it after batch placement and before listen/accept |
| `ds4_bridge_model_free` | `ds4_engine_close` |
| `ds4_bridge_drafter_shared` | read-only DSpark IPC import result; zero for NULL, absent or failed imports |
| `ds4_bridge_session_create` | `ds4_session_create` |
| `ds4_bridge_session_free` | `ds4_session_free` |
| `ds4_bridge_session_sync` | prefix tokens → `ds4_session_sync` |
| `ds4_bridge_session_sync_cb` | one `ds4_session_sync` plus call-scoped durable `prefill_chunk` frontiers |
| `ds4_bridge_sync_step37` | validated Step image crops and expanded prompt → synchronous native vision encoding and full target/MTP refill |
| `ds4_bridge_eval` | `ds4_session_eval` one token |
| `ds4_bridge_iquest_trial` | recursive embedded MTP proposals and sequential target verification; caller-owned token/argmax arrays; returns pending trial width, 0 for ordinary fallback, or -1 on failure |
| `ds4_bridge_iquest_commit` | commit an accepted prefix of the pending trial; restore rejected target/MTP ring writes and normalized hidden carry before publishing the frontier |
| `ds4_bridge_glm53_trial` | embedded proposals and width-one target verification; borrowed token/argmax outputs, pending native journal, 0 for ordinary fallback and -1 on failure |
| `ds4_bridge_glm53_commit` | restore the recorded accepted prefix, including recurrent state, pool tails and predictor frontier; never re-forward to commit |
| `ds4_bridge_session_argmax` | greedy next id |
| `ds4_bridge_session_pos` | native committed timeline (host `SessionLedger` is authoritative) |
| `ds4_bridge_session_ctx` | session context length |
| `ds4_bridge_session_rewind` | `ds4_session_rewind` |
| `ds4_bridge_session_invalidate` | `ds4_session_invalidate` |
| `ds4_bridge_session_generation` | Inc 5a content generation |
| `ds4_bridge_session_prefill_cap` | chunk cap used by the native graph |
| `ds4_bridge_session_exaone_rewind_span` | EXAONE sliding-window reuse span |
| `ds4_bridge_session_sample` | `ds4_session_sample` (caller-owned rng) |
| `ds4_bridge_session_save_payload` | path wrapper over `ds4_session_save_payload` (native writes header+tokens+GPU tail) |
| `ds4_bridge_session_load_payload` | path wrapper; `payload_bytes` = file size |
| `ds4_bridge_session_load_payload_range` | path + checked `offset`/`length`; native consumes exactly that embedded DSV4 range |
| `ds4_bridge_tokenize_text` | caller-owned `int32_t *` + cap + `n_out` |
| `ds4_bridge_tokenize_rendered_chat` | same buffer contract, special-token path |
| `ds4_bridge_token_text` | caller-owned byte buffer; C frees the malloc |
| `ds4_bridge_token_eos` | engine EOS / family EOT |
| `ds4_bridge_token_is_stop` | `ds4_token_is_stop` (1/0) |
| `ds4_bridge_model_id` | `ds4_engine_model_id` (syntax dispatch) |
| `ds4_bridge_spec_snapshot` | cumulative process-global draft/hit/quench counters; individually atomic reads into caller-owned POD |
| `ds4_bridge_mem_census_snap` | process-global CUDA census image (seqlock + last-stable torn cache); `supported=0` when the backend keeps no census |
| `ds4_bridge_mem_observe_snap` | typed observation (`status`/`source` + free/total/cuda_free/meminfo) |
| `ds4_bridge_mem_substrate_outstanding` | `ds4_gpu_substrate_outstanding` (0 on Metal/CPU stubs) |
`ds4_bridge_session_sync_cb` borrows its token buffer, callback, and userdata
until the call returns. The callback runs synchronously on the calling thread,
receives only native `prefill_chunk` events (never `prefill_display`), and is
cleared before both successful and failed returns. It must not re-enter
sync/eval. The safe wrapper exposes a scoped `PrefillCheckpoint` instead of the
session; that value permits exact `prompt[..current]` inspection and synchronous
payload save only. Rust contains callback panics before they reach C, and the
host ledger commits the full prompt only after native sync succeeds.
Family/shape identify (`ds4-core::identify_gguf`) is host-owned: it
mmaps GGUF metadata and does **not** call `ds4_bridge_model_open`.
Tensor inventory + `-0000N-of-` shard remap (`ds4-core::TensorInventory`)
is also host-owned; `Model::open` builds that plan before the bridge
call. The family `weights_bind` name catalog (`ds4-core::BindPlan`) is
host-owned and is passed as `ds4_bridge_bind_plan` so native
`ds4_bridge_bind_plan_check` consumes it before `ds4_engine_open`.
The full host inventory (`ds4_host_tensor_dir` / `opt->tensors`) is
installed for that open. When present, native skips `parse_tensors`
and applies the host table (names are `strdup`'d; the Rust
`CString`s die when `Model::open` returns). Optional `opt->shape`
(`ds4_host_shape`) is the host `config_validate` result: native
applies the pinned `g_ds4_shape` literal plus the verified DeepSeek
compress table and skips C `config_validate_model`. Optional `opt->bind` (`ds4_host_bind_map`) is the host-resolved
name→tensor-dir index: when installed, `model_find_tensor` uses it
instead of scanning `m->tensors`. Names not in the map (MTP/DSpark
siblings) fall back to the C walk. When that map is installed, native
also skips the main-model `weights_validate_layout` because
`Model::open` already ran the host table. Host owns the DeepSeek
MTP/DSpark sibling name and expected-layout catalogs (`mtp-flash` /
`dspark-pro`) and can resolve/validate a sibling `BindPlan` against a
host inventory (`--bind-names mtp-flash --bind-plan`). Optional
`opt->mtp_path` / `opt->dspark_path` attach the DeepSeek-only sibling
support models through the same open; the optional `opt->mtp_bind` /
`opt->dspark_bind` maps are host-resolved name→index tables for THAT
sibling's tensor dir (`Model::open_with_support` runs sibling
resolve + expected-layout validation first). Native keeps them in
separate slots, swaps each into the active map only around its own
sibling open+bind window, and skips that sibling's C layout check —
sibling pointer assignment stays C. After base
`weights_bind`, native clears host tensor-dir / bind-map / vocab /
shape so a later sibling `model_open` cannot apply the base GGUF
tables. The C CLI/server
leave tensors/shape/vocab/bind and the sibling paths/maps NULL, so
the GGUF cursor walk, C validate,
C `vocab_load`, C `model_find_tensor` name walk, and C
`weights_validate_layout` (base and sibling) stay the C oracle
behavior. Weight upload / VMM bind stay native. Tokenizer encode / decode / special / stop
(`ds4-core::Vocab`) is host-owned; `Model` keeps the `Vocab` so
native token-string pointers stay valid for the engine lifetime.
`--tokenize` / `--validate` still must not open the engine. Session timeline / sync plan /
rewrite / rewind / generation (`ds4-core::SessionLedger`) is host-owned;
`Session::pos` / `generation` read the ledger. The DSV4 payload prefix
(13×u32 LE header + token ids; magic `DSV4`, version 3) is host-owned
(`ds4-core::payload`). `Session::load_payload_range` reads only that
prefix, rejects overflow / out-of-file ranges before FFI, and passes the
bounded range to native. A bridge-side preflight failure preserves the
existing host ledger; failures after native load begins follow the native
generation and invalidate the host checkpoint. GPU / logits / family tensor
tails stay native.
The Inc 5 continuation registry (`ds4-server::ContRegistry`) is
host-owned: publish / resolve / hold / pin / TTL / bank claim do not
cross FFI. Weight bind and native prefill/eval still go through the
bridge. `Model` tokenize/stop/eos/`token_text` use the host `Vocab`.
The bridge tokenize helpers remain for the C engine path.
Open options stay a small C struct of scalars, `const char *`
paths, and optional borrowed plan/tensor-dir/shape/vocab/bind pointers
(model path, backend enum, thread count, defer-prewarm, then the
optional `mtp_path` / `dspark_path` / `mtp_bind` / `dspark_bind`
sibling fields appended at the end). Do
not pass `ds4_engine_options` by value into Rust — that struct will
keep growing on the C side and is not the ABI.
SSD enable/cold flags and expert-count/byte budgets cross as scalar options;
file I/O, cache slots and device handles remain native. The POD layouts are
source-matched: rebuild Rust and the bridge together after an options change.
`ds4_host_tensor_dir` can carry a named-part mapping plan (`n_shards`,
`shards`). Zero retains the legacy single-file/numbered-split loader.
Rust validates the manifest and supplies borrowed path/size/base descriptors;
native rechecks sizes, page placement and tensor bounds before mapping each
file into the reserved range. It retains file descriptors, not the borrowed
paths. No tensor payload or device handle crosses this boundary.
Token arrays are `const int32_t *` + length. Do not export
`ds4_tokens`.
## Ownership
| Object | Allocated by | Freed by | Rust view |
|---|---|---|---|
| `ds4_bridge_model` | bridge | `ds4_bridge_model_free` | `NonNull`, `Drop` |
| `ds4_bridge_session` | bridge | `ds4_bridge_session_free` | `NonNull`, `Drop` |
| error buffer | Rust caller | Rust caller | `&mut [u8]` scratch |
| token scratch | Rust caller | Rust caller | `&[i32]` |
| Step normalized CHW crops | Rust caller | Rust caller after synchronous sync | borrowed F32 buffers and checked span descriptors |
| GPU tensors / graphs | native session | native session free | invisible |
The bridge must not return interior pointers into engine arenas.
Step image sync retains only native-owned GPU features. Rust preflights the
complete image/token budget before decoding; native code rechecks dimensions,
finite pixels and placeholder coverage before GPU mutation. Rejected preflight
preserves the frontier. A failure after encoding begins invalidates target/MTP
state and advances the session generation so Rust discards its checkpoint.
Changed images force refill even when the expanded prompt tokens are identical.
## Native boundary scope
These remain C-internal or later *narrow* additions with their own
review, not dump-the-header expansions:
- `ds4_gpu_*` (thousands of lines of tensor ops)
- `ds4_batch_ctx` / reclaim / governor ledgers (the `/metrics` `/v1/stats` text is host-owned; live census + observation copy through `ds4_bridge_mem_*_snap`, not a bindgen of `ds4_mem_census.h`)
- family-specific test hooks (`ds4_engine_motif3_*`, dots3 logits)
- Metal / CUDA types
- distributed pthread/socket internals
- KV store `kv_buf` / malloc arena
KV and distributed protocols use explicit codecs and file-format modules
in Rust. They do not ride on a giant bindgen. New native exports require a
concrete inference need, reviewed ownership and relevant parity evidence;
keep host policy and profiling annotations out of this boundary.
## Linkage
Native Rust builds link the selected Make backend objects plus
`native/bridge/ds4_bridge.c`. The CUDA Driver API, CUDA Runtime, and
cuBLAS stay on the native link line (`-lcuda -lcudart -lcublas`).
Rust must not add a second CUDA stack (cudarc-driven kernels, a
second context, a second VMM arena).
## Versioning
The bridge is a source-matched contract. A signature or descriptor change
updates the header, native implementation, `ds4-sys`, safe wrappers and
relevant parity tests together. The production Rust consumer is `ds4-sys`;
C proof programs may also include the bridge header.
There is no promise of binary compatibility with out-of-tree callers. The
[v0.1.0 boundary](../releases/v0.1.0.md) establishes stable ownership and
behavior; it does not freeze an obsolete migration-era symbol count.