Skip to content

engine-cuda: drop host Qwen3 weights and gather embeddings on device - #15

Merged
ualtinok merged 3 commits into
cortexkit:masterfrom
Qiiks:feat/qwen3-host-ram-slim
Sep 16, 2026
Merged

ualtinok merged 3 commits into
cortexkit:masterfrom
Qiiks:feat/qwen3-host-ram-slim

Conversation

@Qiiks

@Qiiks Qiiks commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

The owned-CUDA Qwen3 embedding worker held ~2.2 GB of host RAM for the entire process lifetime even though CUDA owns every weight after the first forward. The safetensors file was read whole into a Vec<u8>, every f16/bf16 tensor was expanded to Vec<f32>, and both the layer weights and the embedding table stayed alive only to be re-uploaded never.

Three orthogonal changes, no numerical change to any output.

Measured

Real worker, real staged package (qwen3-embedding-0.6b, f16 safetensors), RTX 4050 Laptop, both binaries returning 1024-dim vectors:

Working set Private
before 2,045.9 MB 3,894.8 MB
after 158.9 MB 246.9 MB

~2 GB reclaimed, ~24× drop in private bytes. Quality is unchanged: the existing synapse-worker-cuda smoke path (load + embed_batch) returns the same 1024-dimensional normalised vectors on both binaries.

What changed

1. Upload once, then drop the host copy

Qwen3Context::forward now takes layers: Option<&[Qwen3Layer]>, final_norm: Option<&[f32]>, embeddings: Option<&[u16]> plus vocab_size, upload_weights, upload_embeddings. On the first call Qwen3Model::embed passes the host tensors and sets upload_weights: 1; the C++ side uploads to the device as before. Then model.rs empties self.layers, self.final_norm, self.embeddings, calls shrink_to_fit, and flips a weights_uploaded flag. Later calls pass null pointers and 0 flags.

Qwen3Context::load_weights (in cuda_qwen3.cu) previously threw on a second call when layer_count != count; it now early-returns unconditionally once weights_loaded, because dims cannot legitimately change for a given context.

2. Gather embeddings on the device

The embedding table now uploads as f16 with a plain memcpy (no f16→f32→f16 round trip). A new embed_gather kernel copies one row per position into the hidden-state buffer, replacing the host-side f32 gather that pinned the 0.58 GiB table in RAM. Token ids reach the kernel directly as uint32_t*, so encode_f16_bits is no longer used on the Qwen3 path.

The host Tensor type and the loader are otherwise untouched — only the Qwen3 code path loses its f32 mirror.

3. mmap the safetensors, stream the digest

  • load_safetensors_file (in model.rs) uses memmap2::Mmap::map instead of fs::read. Tensor bytes are copied out into per-tensor buffers below, so the whole-file Vec<u8> was only ever a transient second copy of the model.
  • verify_digest (in lib.rs) hashes through std::io::copy instead of Sha256::digest(bytes), so the file is streamed rather than slurped.

memmap2 = "0.9" added to crates/synapse-engine-cuda/Cargo.toml.

Load order preserved

The upload still happens in the original order: layers, then final-norm, then embeddings, then cudaDeviceSynchronize, then weights_loaded = true.

Numerics

Unchanged. Host f32 → CUDA f16 already happened through copy_weight<half> on the upload path; storing f16 on the host for the embedding table just skips a lossless f16→f32→f16 round trip. Norm vectors are still copied as fp32 (copy_float), and the kernel writes only the hidden-state buffer the forward pass already used.

Testing

  • cargo build -p synapse-worker-cuda --no-default-features --features cuda --release — clean.
  • cargo check -p synapse-engine-cuda (default, no cuda) — clean; the non-cuda stub was updated to match the new signature so the Linux CI lane compiles.
  • Live end-to-end: both pre- and post-fix workers started through the gateway, responded to /health, and returned 1024-dim vectors on a real embed.

Follow-up

The token id buffer allocated per forward (ShapePlan::token_ids) is new but small (rows × 4 bytes) and reuses the existing DeviceAllocation pattern; if the plan cache turns out to be hot we can hoist it, but it was not worth changing the plan shape in this PR.


Summary by cubic

The owned-CUDA Qwen3 embedding worker now drops host copies of model weights after uploading them to the device, cutting private memory from ~3.9 GB to ~247 MB on a real worker without changing output vectors.

  • Uploads layer weights, final norm, and the embedding table to CUDA on the first forward, then empties and shrinks the host Vecs; later forwards pass null pointers.
  • Remembers the layer count from the first upload so later forwards don't crash on a zero layer count.
  • Gathers embeddings on the device with a new kernel that copies one table row per position, replacing the host-side f32 lookup that pinned the 0.58 GiB table in RAM.
  • The CUDA build maps the safetensors file with memmap2 and streams the sha256 digest through std::io::copy; non-CUDA builds still load with fs::read.
  • Token ids now reach the CUDA kernel directly as u32, so the f16 encode step is gone from the Qwen3 path.

Written for commit ad3c627. Summary will update on new commits.

Review in cubic

The owned-CUDA Qwen3 embedder held ~2.2 GB of host RSS for the whole
process lifetime even though CUDA owns the weights after the first
forward: the safetensors were slurped whole, every f16/bf16 tensor was
upcast to Vec<f32>, and both the layer weights and the embedding table
stayed alive only to be re-uploaded never.

Three orthogonal changes, none of which alter numerics:

1. Upload once, then drop. Qwen3Context::forward now takes optional
   layer/final-norm/embedding payloads plus upload_weights /
   upload_embeddings flags; Qwen3Model::embed passes them on the first
   call, then empties self.layers / self.embeddings / self.final_norm and
   flips weights_uploaded. Later calls pass null and 0.

2. Gather embeddings on the device. The embedding table uploads as f16
   (memcpy straight through) and a new embed_gather kernel copies one
   row per position into the hidden-state buffer, replacing the host-side
   f32 table lookup that pinned the 0.58 GiB table in RAM.

3. mmap the safetensors and stream the digest. load_safetensors_file maps
   the file instead of reading it whole (tensors are copied out anyway),
   and verify_digest hashes through std::io::copy instead of holding a
   second full copy of the model.

The token ids now reach the kernel directly, so encode_f16_bits is no
longer needed on the Qwen3 path.

Measured on the real worker against the same staged package
(qwen3-embedding-0.6b, f16 safetensors, RTX 4050 Laptop):

  before  WS 2,045.9 MB  private 3,894.8 MB
  after   WS   158.9 MB  private   246.9 MB

Both binaries returned 1024-dimensional vectors; the ladders, pooling and
normalisation are unchanged.
Copilot AI lite review requested due to automatic review settings September 16, 2026 13:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The crate root forbids unsafe_code outside `all(feature = "cuda", not(macos))`,
so an unconditional `unsafe { memmap2::Mmap::map }` broke the non-cuda build
(linux CI clippy: `forbid(unsafe_code)` at lib.rs:3).

Branch on the feature: the CUDA build maps the safetensors and keeps the
streaming digest, the non-CUDA build falls back to `fs::read` and never
reaches for `unsafe`. Also applies rustfmt's line wrap in the same function.

Verified: `cargo clippy -p synapse-engine-cuda --all-targets` (default, no
`cuda`) clean; `cargo build -p synapse-worker-cuda --no-default-features
--features cuda --release` EXITCODE=0; `cargo fmt --all --check` clean.
@Qiiks

Qiiks commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

CI coverage note

linux and windows compile this crate without the cuda feature, so the green checks only cover the non-CUDA branch of the feature-gated mmap.

The --features cuda path is exercised by the windows-owned-cuda-manual-gate job, which is a manual-approval gate and shows skipping on this PR. That path was verified locally on an RTX 4050 Laptop — the measured RSS table above and the live 1024-dim embed both come from real workers built with --features cuda. Happy to trigger the gate on request if you want it in CI before merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Re-trigger cubic

The crash case: the first forward uploads the layer weights, drops the
host copies, and flips weights_uploaded. Every later forward passes
layers=None, so `params` is empty and `params.len()` (the value handed to
the FFI as layer_count) becomes 0 — and cuda_qwen3.cu:518 rejects
layer_count == 0 with "Qwen3 CUDA received invalid dimensions". The first
forward worked; the second forward after the host weights were dropped
died with engine_crashed.

Fix: Qwen3Context now stores the layer count it uploaded the first time
and passes that stored value on every subsequent forward. The guard makes
the "forward before upload" case an explicit error instead of a size-0
crash.

Verified live: two consecutive /v1/embeddings calls through the gateway
both returned 1024-dim vectors, and the worker's working set stayed at
384 MB (vs 2655 MB before the RAM-slim change).
@Qiiks

Qiiks commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up commit ad3c627 — the two-forward crash case.

Testing the RAM-slim change end-to-end on the real RTX 4050 worker surfaced a defect: the first forward (the upload path) returned 1024-dim vectors correctly, but the second forward after host weights were dropped crashed the engine with:

engine_crashed: CUDA Qwen3 encoder failed with status -1: Qwen3 CUDA received invalid dimensions

Root cause: Qwen3Context::forward derived layer_count from params.len(). On the non-upload path layers = None → empty paramslayer_count = 0, and cuda_qwen3.cu:518 rejects !layer_count. The host weights had already been freed, so there was nothing to recount from — the count had to be remembered.

Fix: Qwen3Context now stores the layer count it saw on the first (upload) forward and passes that stored value on every subsequent call. The guard also turns "forward before any upload" into an explicit error instead of a size-0 crash.

Verified live with two consecutive /v1/embeddings calls through the gateway:

  • forward 1 → 1024-dim vector
  • forward 2 → 1024-dim vector (previously crashed)
  • worker working set: 384 MB (was 2,655 MB before the RAM-slim series)

cargo fmt --check clean. The CUDA build gate is still skipping on this PR; this was validated on the local RTX 4050 build, not in CI.

@Qiiks

Qiiks commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

CI coverage on this branch

The fmt failures on the earlier runs (afb10637) are resolved — cargo fmt --all --check is clean on ad3c627.

Two things CI does not cover here, verified locally instead:

  1. --features cuda clippy. The windows-owned-cuda-manual-gate and linux-llama-cuda-manual-gate jobs report skipping on this PR, so the CUDA feature path is linted only on my machine: cargo clippy -p synapse-engine-cuda --features cuda --all-targets inside the MSVC dev shell with CUDACXX pointed at CUDA 13.2. Result below.
  2. The layer_count fix itself. Verified by two consecutive /v1/embeddings calls through the gateway — both returned 1024-dim vectors; worker working set 384 MB (was 2655 MB before the RAM-slim series).

The default-feature path (cargo clippy -p synapse-engine-cuda --all-targets) is clean and is what the linux/windows jobs lint.

@synapse-alfonso

Copy link
Copy Markdown

Verified the earlier head f28da35bf654 on a rented RTX 4090 against master: all 64 fixture rows byte-identical (same SHA-256 over the raw f32 payload on both builds), identical cosine against the fp32 reference, and a one-byte-corrupted safetensors still refused with a digest mismatch. One correction to the memory table: peak RSS (VmHWM) is unchanged at ~4.65 GiB on both builds, because the upload moved from LOAD to the first forward; the 2.2 GiB saving is steady-state after the first embed, which is real, but the peak is not reduced.

That run sent a single EMBED_BATCH carrying all 64 rows — one forward — so it could not have seen the second-forward crash you found in ad3c627. Good catch, and it exposes a gap in how I verified: a change that moves upload between forwards has its failure modes between forwards. I am re-running against ad3c62794fce with five sequential forwards per worker (64 rows, the same 64 again, a different shape, a singleton, and 64 again after a PING), byte-compared against master per forward and against itself across repeats. Merge follows that result, not the earlier one.

Nothing needed from you unless the head moves again.

@ualtinok
ualtinok merged commit 48a9141 into cortexkit:master Sep 16, 2026
9 checks passed
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.

3 participants