Skip to content

feat(cuda): ggml-CUDA EdgeTAM backend (NVIDIA fast-path)#3

Draft
njayj wants to merge 16 commits into
claude/sam3cpp-coreml-trackerfrom
claude/cuda-nvidia-edgetam
Draft

feat(cuda): ggml-CUDA EdgeTAM backend (NVIDIA fast-path)#3
njayj wants to merge 16 commits into
claude/sam3cpp-coreml-trackerfrom
claude/cuda-nvidia-edgetam

Conversation

@njayj

@njayj njayj commented Jun 23, 2026

Copy link
Copy Markdown

What

Wires the already-vendored ggml-CUDA backend into the EdgeTAM engine — the NVIDIA sibling of the existing SAM3_VULKAN (cross-vendor) and Metal/CoreML (Apple) backends. Pure wiring, zero edits under ggml/src/ggml-cuda/ (61 .cu files already present).

3 commits:

Why not TensorRT

Egor's part-2 blog optimizes the ONNX/TensorRT EfficientTAM stack; those kernel tricks don't transfer to ggml. This is the ggml-CUDA backend for the EdgeTAM engine the Iris pipeline actually ships — mirrors the Vulkan precedent exactly.

Cross-repo

Pairs with the platform PR (same branch name claude/cuda-nvidia-edgetam) which adds the cgo tracker_cuda.go binding and vendors this build's libsam3.a. Platform PR: (linked below).

Validation

  • ✅ Default Mac build (-DSAM3_METAL=ON -DSAM3_COREML=ON) green at all gates — the CUDA additions are inert when SAM3_CUDA=OFF.
  • Hardware-gated (no NVIDIA box in the loop): -DSAM3_CUDA=ON configure/compile + on-GPU accuracy/FPS are deferred to the platform plan's D1 hardware gate, exactly as the Vulkan backend shipped UNVALIDATED.

🤖 Generated with Claude Code


Update 2026-07-04 — review fixes + encoder-ahead producer (commits 2604544, a3194c7)

Fork-Reason: guarded runtime backend-fallback chain (CUDA→Vulkan→CPU) — upstream's loader logs before init and lets ggml_backend_vk_init throw out of sam3_load_model on Vulkan-less machines.
Fork-Reason: SAM3_CAPI_SHARED C-ABI DLL target + ET_API exports — required to cross the MSVC(nvcc)/mingw(cgo) toolchain boundary on Windows; upstream has no cgo consumer.
Fork-Reason: backend-agnostic prefetched-neck seam + sam3_encoder_ahead_* ggml-CUDA producer — the CoreML encoder-ahead threading equivalent for NVIDIA, consumed by the platform's -tags sam3cuda binding.
Fork-Reason: SAM3_CUDA now forces GGML_CUDA_GRAPHS=ON — standalone-ggml default OFF silently dropped the CUDA-graphs optimization.

Details: loader chain now probes device counts, logs AFTER successful init, wraps the Vulkan probe in try/catch (vk::SystemError on driverless machines no longer fails the model load, it falls to CPU). The prefetched-neck seam (s_prefetch_neck + sam3_coreml_set_prefetched_neck — historic name kept for source compat) moves out of #ifdef SAM3_COREML; edgetam_load_neck_from_floats is factored and shared by the CoreML helper and the new ggml consume path; sam3_encoder_ahead_{create,encode,destroy} run the RepViT+FPN encoder graph on a second CUDA backend instance (own stream, shared read-only weights). The capi wires the pool behind SAM3_GGML_ENCODER_AHEAD=1, default OFF pending the on-GPU A/B (platform TestEncoderAheadThroughput, now tag-widened to sam3cuda).

Validation: -fsyntax-only across {plain, SAM3_COREML, GGML_USE_CUDA, GGML_USE_VULKAN, CUDA+VULKAN, ET_CAPI_BUILD_DLL}; full Mac cmake build (SAM3_COREML=ON) + sam3_edgetam_bench link green — the CoreML path is regression-free. CUDA/Vulkan compile+run remain hardware-gated as before.

@njayj

njayj commented Jun 23, 2026

Copy link
Copy Markdown
Author

Cross-repo platform PR (cgo binding + vendoring, stacked on #908): https://github.com/tryiris-ai/platform/pull/920

njayj and others added 13 commits July 4, 2026 10:11
… SAM3_CUDA

- SAM3_CUDA now forces GGML_CUDA_GRAPHS=ON: standalone ggml defaults it OFF
  (llama.cpp-only flag), so the previously advertised 'CUDA graphs' path was
  never compiled. ggml auto-falls back at runtime for uncapturable graphs.
- New SAM3_CAPI_SHARED option builds the cgo C-ABI as its own shared library
  (sam3capi.dll). This is the Windows/CUDA toolchain bridge: nvcc requires
  MSVC as host on Windows, and MSVC-built C++ static libs cannot link into a
  mingw cgo binary — a C-ABI DLL is toolchain-neutral. ET_API in
  edgetam_capi.h carries the dllexport (extern "C" alone does not export
  from an MSVC DLL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loader (fix): the CUDA/Vulkan selection logged 'using X backend' BEFORE init
and unconditionally — a machine without the device logged the wrong backend.
Now: device-count guard, log AFTER successful init, explicit fallback warning.
ggml-Vulkan THROWS (vk::SystemError) when the Vulkan loader/driver is absent —
the probe+init are now wrapped so a GPU-less machine falls through to the CPU
backend instead of failing the whole model load (fat-build CUDA->Vulkan->CPU
chain is now safe end to end).

Encoder-ahead (feat): the CoreML-threading equivalent for NVIDIA. The
prefetched-neck seam moves out of #ifdef SAM3_COREML (it is raw floats — no
CoreML dependency); a factored edgetam_load_neck_from_floats() is shared by
the CoreML helper and the new ggml consume path in edgetam_encode_image.
sam3_encoder_ahead_{create,encode,destroy} run the EdgeTAM RepViT+FPN encoder
graph on a SECOND ggml-CUDA backend instance (own stream; shared read-only
weights) so frame N+1's encode overlaps frame N's mem-attn/decoder. The capi
wires it behind SAM3_GGML_ENCODER_AHEAD=1 (default OFF: same-GPU
producer/consumer contention must be A/B-measured on real NVIDIA hardware —
the M4 sweep showed co-location can lose; pool_size()==0 keeps the
synchronous path otherwise).

Verified: -fsyntax-only across {plain, SAM3_COREML, GGML_USE_CUDA,
GGML_USE_VULKAN, CUDA+VULKAN, ET_CAPI_BUILD_DLL}; full Mac cmake build
(SAM3_COREML=ON) + sam3_edgetam_bench link green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n lacks

ggml-CUDA's fused flash-attention kernels only cover head sizes
{40,64,72,80,96,112,128,256,576} (ggml-cuda/fattn.cu). EdgeTAM's SAM
mask-decoder attention uses head_dim=32, so ggml_cuda_get_best_fattn_kernel
returns BEST_FATTN_KERNEL_NONE and GGML_ABORTs on the first Track() — while
model load and the RepViT encoder run fine on CUDA0. Metal and Vulkan accept
head_dim=32, so this gap is CUDA-specific and never surfaced on those backends.

Add sam3_fa_ext(), a wrapper around ggml_flash_attn_ext. On the CUDA build only
(#ifdef GGML_USE_CUDA) it routes head dims outside the supported set through a
mathematically equivalent manual attention — the exact QK^T -> soft_max_ext ->
.V -> permute(0,2,1,3) idiom already used interchangeably with flash in
sam3_ddec_layer_forward, so the [HD,NH,N_q,B] output layout is identical and no
call site changes. Flash is still used on CUDA for supported head dims (encoder,
memory attention), preserving perf there. On non-CUDA builds sam3_fa_ext forwards
verbatim to ggml_flash_attn_ext, so the Metal/CoreML graph and numerics are
byte-for-byte unchanged. All 17 flash-attn call sites route through the wrapper.

Enables the Windows x64 + CUDA EdgeTAM 512 hold to run per-frame inference on
NVIDIA GPUs (RTX 4060 / Ada sm_89, CUDA 12.9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck_trk edges

report/CUDA-RESIDENCY-DESIGN.md §5 increment 1. Adds the sam3_transport driver
(host default on every backend = today's exact tensor_get/tensor_set sequence;
CUDA device opt-in via SAM3_STAGE_TRANSPORT=device = one same-backend D2D
ggml_backend_tensor_copy) and converts the four heaviest inter-stage edges:
mem-attn curr (with the 4D-leaf + in-graph-reshape fix so D2D layouts match;
host graph bit-for-bit unchanged), decoder trk_s0/trk_s1, memenc pix_in_raw.
Removes 4 D2H + 4 H2D = ~176.7 MB/frame (76% of PCIe bytes).

Gates: host-mode output byte-identical to pre-change baseline; host-vs-device
A/B byte-identical over 39 synthetic frames incl. masks; 7.3 -> 9.5-9.7 fps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad caches

report/CUDA-RESIDENCY-DESIGN.md §5 increment 2. sam3_stagebuf residency handle:
the existing host vector stays the value; device mode adds a persistent twin
uploaded once per content generation, then D2D into the fresh graph input.
Twins: rope_q/rope_k/src_pos (tracker, keyed on pe_gen), sparse/image_pe/
dense_emb (state, keyed on pe_cache_gen). Load-time host caches (device driver
only) for the per-frame weight re-reads: obj-ptr MLP W/b, no_obj_ptr,
perceiver latents_1d/2d, maskmem tpos. Host driver byte-identical: pushes are
verbatim tensor_set, caches stay empty.

Gates: host-mode byte-identical to pre-change baseline; host-vs-device A/B
byte-identical; 9.6-9.8 fps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ssembly

report/CUDA-RESIDENCY-DESIGN.md §5 increment 3.

Ring: bank slots + obj pointers now recycle their device tensors on eviction
(sam3_acquire_slot_pair / recycle free-lists) instead of leaking buffers until
reset (~263 KB VRAM per credible frame) and accreting tensor metadata in
tracker.ctx (exhaustion at ~1365 credible frames). Whole-bank instance
eviction recycles too. Host-driver bytes unchanged (same tensor_set order);
only the allocation pattern differs.

Device prompt assembly (CUDA device driver + EdgeTAM + full steady-state
capacity only; ramp frames keep the verbatim host path — no padding): the
prompt's spatial region is concatenated IN-GRAPH from fresh slot leaves fed by
D2D from the bank ring, with the tpos broadcast add done on device (single
fp32 add — bit-exact vs the CPU add). The pointer region is built by the
exact host code path (sam3_build_prompt_and_pos with empty slots) and
uploaded (~34 KB). Removes the per-frame slot downloads (1.8 MB D2H), the
917 KB prompt/pos uploads, and 16 pointer D2H gets + their syncs. Obj-ptr
tpos-proj weights added to the load-time cache; host mirror of pointers
maintained alongside ptr_banks.

Gates: host-mode byte-identical to pre-change baseline; host-vs-device A/B
byte-identical at 40 frames AND a 300-frame soak (19.6 MB, ring wraparound;
"device prompt assembly engaged (7 slots x 512 tokens + 64 ptr tokens)"
asserted in stderr). Steady-state device fps 10.46 (from 9.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
report/CUDA-RESIDENCY-DESIGN.md §5 increment 4 (device driver + EdgeTAM only;
SAM2.1 keeps the verbatim host path — its no_obj_embed_spatial branch mutates
the memenc output on the host). The perceiver now feeds its two sub-graphs by
same-backend D2D from the LIVE memenc output tensor (mo) while its graph
memory is still allocated — the 1 MB per-frame download of md disappears. The
1D path uses the 4D-leaf + in-graph-reshape pattern; the 2D path performs the
window partition IN-GRAPH (reshape/permute/cont/reshape — a pure byte gather,
bit-identical to edgetam_window_partition_cpu, verified by the byte-diff
gate). Perceiver pos (cached_sinpe_64) and latents ride persistent twins.

Gates: host-mode byte-identical to pre-change baseline; host-vs-device A/B
byte-identical at 40 frames and a 300-frame soak; 10.0 fps smoke / 10.45 fps
steady-state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…D feeds

report/CUDA-RESIDENCY-DESIGN.md §5 increment 5 (the severable core). The CUDA
backend now DEFAULTS to the device transport; SAM3_STAGE_TRANSPORT=host is the
runtime escape hatch. The flip sits inside ggml_backend_is_cuda — Metal/
Vulkan/CPU can never take it, so every non-CUDA backend keeps today's host
path unconditionally. All device-path D2D feeds (stage_feed + stagebuf twin
copies) switch to ggml_backend_tensor_copy_async on the backend's compute
stream: same-stream FIFO orders each copy before the graph_compute that
consumes it, removing the per-copy stream fence. Every feed source is
long-lived (state/ring/twins, or a graph output whose gallocr outlives the
consuming compute), so no copy can read recycled memory. Twin UPLOADS stay
synchronous (host-side sources may be transient).

Deferred (severable, per design §5-inc5): per-stage ctx/graph/gallocr reuse
for CUDA-graph replay (needs the CLAUDE.md rule-1 addendum + owner sign-off),
pinned host staging for the frame/mask pair, and the encoder-ahead
cross-instance copy_async handoff (the transport logs a note when
SAM3_GGML_ENCODER_AHEAD is combined with device transport).

Gates: host-mode byte-identical to pre-change baseline; host-vs-device A/B
byte-identical at 40 frames and a 300-frame soak; default engagement asserted
("stage transport = device (CUDA-resident, default)"); 10.0-10.5 fps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path)

CLAUDE.md graph-isolation addendum 4 (owner-approved): on the CUDA device
driver a stage may keep its ggml_context/cgraph/gallocr alive across frames
and recompute in place, keyed on every topology-affecting shape/config and
released on any key change / reset / compute failure. Applied to the fused
mem-attn + mask-decoder graph in sam3_propagate_single (sam3_prop_gcache on
the tracker): steady-state frames skip the graph rebuild AND the gallocr
reserve+alloc entirely — no per-frame cudaMalloc/free, stable leaf data
pointers (the precondition for ggml-CUDA graph capture to reach replay).
Ramp frames (growing M_total) miss the key and rebuild as before. Host
driver keeps the verbatim build→compute→free per call.

Gates: host byte-identical to pre-program baseline; host-vs-device A/B
byte-identical at 40 frames + 300-frame soak; 10.45 fps smoke, 11.11 fps
steady state (from 10.0/10.5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same CLAUDE.md addendum-4 contract as 6a, applied to the three remaining
per-credible-frame graphs: the memory encoder (sam3_stage_gcache on the
tracker; keyed on H/INTERPOL/D) and the perceiver 1D/2D sub-graphs (keyed on
their fixed dims + device flag). Steady state skips build+reserve+alloc for
all of them; the cached memenc output tensor (mo) keeps a frame-invariant
address, which also stabilizes the perceiver's D2D feed source. Compute
failure releases the affected cache. Host driver unchanged.

Gates: byte-identity A/B 40f + 300-frame soak; 10.81 fps smoke, 11.28 fps
steady state (from 10.45/11.11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same addendum-4 contract applied to the largest per-frame graph (RepViT +
FPN neck, runs every frame): sam3_stage_gcache on sam3_state, keyed on
img_size/n_fpn, released in sam3_free_state / on compute failure. Steady
state skips the encoder's build+reserve+alloc entirely; the FPN output
tensors keep frame-invariant addresses feeding the existing D2D
fpn->neck_trk fast path. Pinned host staging for the 12.6 MB frame upload
evaluated and deferred: preprocess returns a fresh vector per frame, so
pinning needs a preprocess-into-persistent-buffer restructure for ~1 ms —
logged as backlog.

Gates: byte-identity A/B 40f + 300-frame soak; 11.49 fps smoke, 12.05 fps
steady state (from 10.81/11.28).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The encoder-ahead handoff moved 2×84 MB of neck floats through the host per
frame (producer tensor_get -> Go slot buffers -> consumer tensor_set), which
capped the overlap win at 1.04x. The device seam keeps everything on-GPU:
the producer encodes with a CACHED graph (addendum-4; producer-stream CUDA
graph replay) and same-instance-D2D's the three FPN levels into per-slot
persistent device tensors (+ one producer sync so writes are complete before
the Go channel signals — happens-before across threads); the consumer's new
device-prefetch branch in edgetam_encode_image D2D-feeds them straight into
state.neck_trk. Zero PCIe on the seam. Falls back to the host-float seam for
host transport / CoreML (that path is untouched). capi: encode_slot tries the
device seam first; track_slot prefers it per slot.

Measured (RTX 4060, device transport): serial 79.2 ms/frame -> threaded
55.7 ms/frame = 18.0 fps (speedup 1.42x, was 1.04x with the host seam).
J2-13 gate: threaded-vs-serial outputs BYTE-IDENTICAL over a 299-frame
moving-subject A/B (boxes, scores, states, masks) — the second CUDA instance
produces bit-identical necks, so encoder-ahead costs zero accuracy here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coreml/sam3_preproc.cu: bit-exact CUDA twin of sam3_resize_bilinear
(double chain, -fmad=false — load-bearing for the byte gate) + ImageNet
normalize, writing f32 CHW straight into the encoder input tensor's
device memory. Replaces ~14ms single-threaded CPU preprocess + 12.6MB
f32 H2D with a ~2.7MB u8 upload on the device-transport path.
SAM3_PREPROC_DEVICE: unset/1 = device (when transport==DEVICE);
0 = shipped CPU path byte-identical; 2 = VERIFY (both paths, memcmp,
abort on mismatch). Fail-soft CPU fallback on any CUDA error.
Encoder-ahead producer path unchanged (CPU preprocess; default-off).
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.

2 participants