Skip to content

Honor user tensor_split when auto tensor-parallel planner returns an even split - #10884

Open
chakshu-dhannawat wants to merge 13 commits into
unslothai:mainfrom
chakshu-dhannawat:fix/auto-tensor-split-fallback-10355
Open

Honor user tensor_split when auto tensor-parallel planner returns an even split#10884
chakshu-dhannawat wants to merge 13 commits into
unslothai:mainfrom
chakshu-dhannawat:fix/auto-tensor-split-fallback-10355

Conversation

@chakshu-dhannawat

@chakshu-dhannawat chakshu-dhannawat commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

In auto tensor-parallel mode, the planner can decide the model fits evenly across GPUs and returns tp_tensor_split=None. The backend then emitted --split-mode tensor but omitted --tensor-split, so a user-supplied ratio was silently ignored.

This change falls back to the user-supplied tensor_split when the planner emits nothing, after checking the entry count matches the active GPU count and the total is non-zero.

Includes a regression test.

Fixes #10355


UI evidence

The PR touches one line of studio/frontend/src/features/chat/lib/resident-config-match.ts, so the question a reviewer asks is whether the chat behaves differently. Net of the merge base it does not, and that is what this pair shows: two isolated Studio installs, BEFORE built from the merge base 0d9952f8 and AFTER from the head, each loaded with the same GGUF in auto GPU-memory mode, then driven through the model picker to select the model that is already resident.

BEFORE and AFTER: selecting a resident model in the chat picker

Both halves show the same chat: Qwen3-0.6B-GGUF / GGUF - Q4_K_M loaded in the header, the context meter at 24 / 4.1k, and the composer enabled. The claim behind the picture is a measurement rather than the pixels, since an adoption and a reload end up looking the same: the llama-server pid running before the selection is the same pid after it on both sides, so the picker adopted the resident server instead of tearing it down. Scene facts came out identical on both halves: status_gpu_memory_mode auto, status_tensor_parallel false, status_tensor_split null, server_count 1, resident_server_survived_pick true.

Scope of this pair: the changed line's tensor-split arm needs a real tensor-parallel load across two or more devices, and the host that took these shots has one GPU visible, so the line is evaluated on both builds with the split still absent on both. The tensor-parallel behaviour itself is evidenced on two real T4s in the Kaggle run linked in the comments, and pinned in studio/frontend/tests/resident-config-match.test.ts.

…ven split

In auto tensor-parallel mode the planner can decide the model fits evenly
across GPUs and return tp_tensor_split=None. Previously the backend then
omitted --tensor-split entirely, so a user-supplied per-GPU ratio was
silently ignored.

Fallback to the user-supplied tensor_split in that case, after sanity
checking that the number of entries matches the active GPU count and that
the total is non-zero.

Regression test included.

Fixes unslothai#10355

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 716d8a6e6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

]
)
_emitted_tensor_split = ",".join(str(int(x)) for x in tp_tensor_split)
elif gpu_memory_mode != "manual" and tensor_split:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep auto tensor splits within the planned VRAM budget

When the auto planner returns None, it has established only that an even split fits; it has not validated the caller's ratio. For example, on two cards where the model/context consumes about 60% of each card under the even plan, a valid [3, 1] ratio passes this gate and assigns roughly 75% of the weights to one card, causing llama-server startup to OOM. Re-plan or reject a custom ratio against each selected GPU's usable budget rather than forwarding it after an even-only plan.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, and fixed. 0492a22 added _tensor_split_fits_budget, which re-checks the requested ratio against each selected GPU's usable budget before the fallback forwards it. f7b93a0 then brought that check to parity with the planner's own arithmetic: it was missing the per-device compute-buffer term the planner charges, so a ratio the planner would have rejected could still pass the gate, and an index missing from the free-memory map now fails closed instead of being treated as unbounded. Covered by studio/backend/tests/test_pr10884_auto_tensor_split_sim.py.

Comment on lines +23200 to +23204
_emitted_tensor_split = ",".join(
f"{x:g}" for x in _sanitized_split
)
if _emitted_tensor_split is not None:
cmd.extend(["--tensor-split", _emitted_tensor_split])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track auto-mode tensor splits for reload matching

This makes tensor_split affect an auto launch, but auto mode still clears _tensor_split and _runtime_matches_intent compares the split only in the manual branch. Consequently, after an auto tensor-parallel load with [3, 1], a request for the same model/settings with [1, 3] is adopted as already loaded and never respawns, leaving the old distribution active. Store and compare the emitted auto ratio (or otherwise include it in the auto intent matcher).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, and fixed. 0492a22 added _auto_tensor_split plus an auto-mode arm in _runtime_matches_intent, so a changed ratio no longer adopts the resident server. f7b93a0 corrected what gets recorded: the field held the ratio that was EMITTED, which is the planner's ratio whenever the planner produced one, so a later request carrying the user's own ratio compared unequal to it and every identical repeat reloaded forever. It now records the requested ratio, normalized, and the matcher compares the same normalization (3,1 and 75,25 are one intent to llama.cpp). Hardware evidence on two T4s in #10884 (comment): an identical repeat reuses pid 2730, a reversed ratio respawns as pid 2769 with --tensor-split 1,3.

pre-commit-ci Bot and others added 2 commits September 14, 2026 02:03
… dedupe

- Budget validation: when the auto tensor-parallel planner returns an even
  split (None) and the user supplied a ratio, check the ratio against the
  same per-GPU usable budget the planner uses. If it would overshoot a GPU,
  drop the ratio instead of forwarding it to llama-server.
- Reload matching: record the emitted auto-mode ratio in
  _auto_tensor_split and compare it in _runtime_matches_intent so a later
  request with a different ratio does not silently reuse the wrong split.
- Add regression tests for both behaviors.
@chakshu-dhannawat

Copy link
Copy Markdown
Contributor Author

Pushed updates for both review points:\n\n- The user fallback ratio is now validated against the same per-GPU budget the planner uses, so an overshoot is dropped instead of forwarded.\n- Auto mode records the emitted ratio and compares it in reload deduplication, so a later request with a different ratio does not silently reuse the wrong split.\n\nAdded regression tests for both. Let me know if anything still looks off.

@danielhanchen

Copy link
Copy Markdown
Member

Confirmed this hits the path in studio/backend/core/inference/llama_cpp.py where auto tensor-parallel emits --split-mode tensor without --tensor-split, so a user ratio never reaches llama-server, and the budget check plus reload matching you added both look right. Will get this reviewed.

… loop, budget parity

The fallback itself is right and unslothai#10355 reproduces exactly as described: on two
equal cards with a model that fits, the planner returns no ratio of its own and
the user's --tensor-split never reached llama-server. Four problems in how it
was wired, each reproduced against 0d9952f and each covered by a new test.

1. load_model raised instead of degrading. The placement price lives in one
   long try whose except arm is a designed fallback: it drops the plan, sets
   --fit on and launches anyway. It clears tp_tensor_split but deliberately not
   tensor_parallel, so it steered straight into the new elif with gpu_indices
   either None or rebuilt from every device rather than the ones the reserve
   filter admitted, and with tp_gpus and the MTP terms possibly unbound. That is
   KeyError, TypeError and UnboundLocalError out of load_model on inputs that
   launched fine before, on Vulkan (AMD and Intel), ROCm and CUDA alike. Gated
   on _tp_planned, which is now bound before the try like the other locals that
   arm restores, and _tensor_split_fits_budget fails closed on a device the
   survey does not cover.

2. Identical requests reloaded forever. _auto_tensor_split recorded what was
   EMITTED, and the matcher compared it against what was REQUESTED. Those are
   not the same units: the planner's own split is a list of per-device MiB and a
   ratio the budget check declines is emitted as nothing at all. So an ordinary
   multi-GPU auto tensor load with no user ratio at all mismatched on every
   repeat and tore down a multi-gigabyte model, forever, since the next launch
   recorded the same thing again. Record a normalized fingerprint of the
   request, per the requested-against-requested rule the rest of the matcher
   already uses, which also makes 3,1 and 75,25 the same intent as they are to
   llama.cpp.

3. The budget check was not the planner's budget, in both directions. It
   charged the replicated context buffer twice, declining ratios the planner
   itself would accept, which to a user who typed one reads as the ratio being
   ignored again. It also omitted the planner's soft overhead and priced the
   dequant scratch from the heavier half of an asymmetric K/V pair, which is
   optimistic - and a tensor load has no --fit valve to spill into.

4. The API contract still said "Manual mode only ... Ignored unless
   gpu_memory_mode is 'manual'", which this change makes false.

Also emit the ratio in plain decimal so a legal 1000000,1 does not reach the
log and the argv as 1e+06,1, and clear the recorded ratio on the manual arm
beside the arch-gate resets that are there for the same reason.

tests/kaggle/studio_gpu/run_studio_gpu.py gains the real-hardware half:
assert_auto_tensor_split reads the live llama-server argv from /proc, because
/api/inference/status reports tensor_split: null on this path whatever the
child was launched with, and drives the reuse-and-reload pair that a fix can
regress. --base-sha runs the same probe against the merge base first, in the
same session with one file swapped, and calls the comparison void unless the
base reproduces the defect.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Sep 14, 2026
…ded nothing

Measured on kernel unsloth-t4-ci-d15ea193: two real T4s, --tensor-split 3,1 on
the live argv, reuse and reload both correct, and the assertion still red
because Qwen3-0.6B spent all 16 permitted tokens inside <think> and came back
with an empty content and finish_reason length. Read both channels, count the
usage, and give the model room to finish a sentence.
@danielhanchen

Copy link
Copy Markdown
Member

Tested this on real hardware and pushed fixes directly to the branch. Summary: the bug you are fixing is real and reproduces exactly as you describe, the approach is right, but the wiring had four problems that I have now fixed on top of your commits.

The defect is real

Reproduced on two real Tesla T4s in a Kaggle session, auto mode, tensor_parallel: true, tensor_split: [3, 1], against the merge base 0d9952f8:

base  0d9952f8   --split-mode tensor      --tensor-split (absent)
head  227bfc05   --split-mode tensor      --tensor-split 3,1

Read off the live llama-server process argv in /proc, not off the API. That matters: GET /api/inference/status reports tensor_split: null on this path whatever the child was launched with, because the auto branch never writes self._tensor_split. Anyone checking this through the API alone would see nothing either way.

What I changed

f7b93a0a and 227bfc05. Each of the four was reproduced against 0d9952f8 first, so none of them is a style preference.

1. load_model raised instead of degrading, on Vulkan, ROCm and CUDA. The placement price lives in one long try, and its except arm is a designed fallback: it drops the plan, sets --fit on and launches anyway. It clears tp_tensor_split but deliberately not tensor_parallel, so it steered straight into the new elif with gpu_indices either None or rebuilt from every detected device rather than the ones the reserve filter admitted, and with tp_gpus and the MTP terms possibly unbound. Three distinct crashes come out of that: KeyError, TypeError and UnboundLocalError, on inputs that launched cleanly before. Now gated on _tp_planned, which is bound before the try alongside the other locals that arm restores, and _tensor_split_fits_budget fails closed on a device the survey does not cover.

2. Identical requests reloaded the model forever. _auto_tensor_split recorded what was emitted, and _runtime_matches_intent compared it against what was requested. Those are not the same units. The planner's own split is a list of per-device MiB, and a ratio the budget check declines is emitted as nothing at all. So an ordinary multi-GPU auto tensor-parallel load with no user ratio at all mismatched on every repeat and tore down a multi-gigabyte model, and the next launch recorded the same thing again, so it never converged. Measured on two unequal cards:

emitted --tensor-split 23024,15264 (planner's own), user ratio: none
identical repeat reuses the server:   base 0d9952f8 -> True
                                      head 084fa1f8 -> False

That is a path that worked before this PR. Fixed by recording a normalised fingerprint of the request, which is the requested-against-requested rule the rest of the matcher already uses, and which also makes 3,1 and 75,25 the same intent, as they are to llama.cpp.

3. The budget check was not the planner's budget, in both directions. It charged the replicated context buffer twice, once inside the distributed total and again per device, so it declined ratios the planner itself would accept. To a user who typed one, that reads as the ratio being ignored all over again. It also omitted the planner's soft_overhead_bytes and priced the dequant scratch from the heavier half of an asymmetric K/V pair, which is optimistic, and a tensor load has no --fit valve to spill into when the card it overfilled runs out.

4. Small things. The ratio is emitted in plain decimal now, so a legal 1000000,1 does not reach the log and the argv as 1e+06,1. The recorded ratio is cleared on the manual arm beside the arch-gate resets that are there for the same reason. And LoadRequest.tensor_split still documented itself as "Manual mode only ... Ignored unless gpu_memory_mode is 'manual'", which this PR makes false.

Evidence

Real 2x T4, Unsloth Studio installed the supported way (install.sh --local, CUDA 13 llama.cpp), base and head in one session with only llama_cpp.py swapped, so the two legs differ by this change and nothing else. Kernel unsloth-t4-ci-dc1689a2, all 15 assertions green:

leg llama-server pid argv
base 0d9952f8, ratio 3,1 2621 --split-mode tensor, no --tensor-split
head, ratio 3,1 2712 --split-mode tensor --tensor-split 3,1
head, same request again 2712 server reused, no reload
head, ratio changed to 1,3 2751 reloaded, --tensor-split 1,3

gpu_inference, tool_calling, server_flags and compaction also pass on the same session, so the rest of the GPU path is unaffected.

Simulation matrix, 44 cases driving the real backend against faked inventories: CUDA and Vulkan, 0 to 4 cards, asymmetric cards, drivers that report no total (iGPU), paravirtual Metal, degenerate ratios (wrong length, zeros, negatives, NaN, inf), the planner's recovery arm, and the full reuse and reload matrix.

merge base 0d9952f8      22 failed, 19 passed, 3 skipped     (the bug)
PR head    084fa1f8       8 failed, 36 passed                (bug fixed, 4 introduced)
PR head    227bfc05      44 passed

Full Studio backend suite, 41,258 tests, compared against the merge base run on the same box. Identical failure sets apart from one load-sensitive flake in test_hf_xet_fallback.py that passes in isolation on both revisions. No regressions.

I also added assert_auto_tensor_split to tests/kaggle/studio_gpu/run_studio_gpu.py, so the Kaggle GPU CI keeps covering this, with a --base-sha mode that runs the same probe against the merge base first and calls the comparison void unless the base reproduces the defect.

Two things left for you to weigh in on

  1. self._tensor_split is still None on the auto path, so /status and the /load response report no ratio for a server that is running one. That predates this PR (the planner's own splits were never reported either), and changing it touches the manual dedupe and the VRAM heap accounting, so I left it. Worth a follow-up.
  2. _plan_tensor_parallel returns None partly because an even split is safe for every architecture, including Gemma 3n, which GGML_ASSERTs on a weighted one. Forwarding a user ratio there can abort the launch, which the existing abort latch recovers from by retrying a layer split. Manual mode has always behaved this way, so this makes auto consistent with manual rather than introducing a new class of risk, but it is worth knowing.

Nice find on the original issue, and thanks for the fix. With the above it looks good to me.

- Add _auto_tensor_split_emitted to record the normalized ratio actually
  emitted to llama-server in auto tensor-parallel mode.
- Update the tensor_split property to fall back to that emitted ratio, so
  /status no longer reports null for a server running a concrete split.
- Reset the new field alongside the other split state.
- Update the LoadStatus tensor_split description to remove the manual-only
  wording.
- Add assertion that backend.tensor_split surfaces the emitted ratio.
@chakshu-dhannawat

Copy link
Copy Markdown
Contributor Author

Follow-up push addresses the /status reporting gap danielhanchen flagged:\n\n- Added _auto_tensor_split_emitted to capture the normalized ratio actually passed to llama-server in auto tensor-parallel mode.\n- The tensor_split property now falls back to that value, so /status and the /load response no longer report null for a server that is running a concrete split.\n- Updated the LoadStatus field description to drop the manual-only wording.\n\nTests still green: 236 placement + simulation tests pass, ruff clean.

@danielhanchen

Copy link
Copy Markdown
Member

Cross-platform CI on the fixed branch, run off the org queue so the platforms this change does not touch are exercised too. All five green:

runner arch result
ubuntu-latest x64 Linux pass
ubuntu-24.04-arm ARM64 Linux pass
windows-latest x64 Windows pass
macos-15 Apple Silicon pass
macos-15-intel Intel macOS pass

Worth stating what that does and does not cover, since only Linux has a GPU here. The change is backend Python only: no frontend file, no Tauri resource, no API schema change, and tensor_split has never been written to disk (it is not in the normalize_model_override allow-list), so no existing install can carry an auto-mode ratio across an upgrade and the first load after updating replays a byte-identical argv. There is also no tensor-split control in the Studio UI, so no browser is involved in reaching this path. The real GPU answer is the Kaggle 2x T4 run in the previous comment.

Reporting the auto ratio is the right call, but it feeds one comparison that
was written when auto could only ever report null. resident-config-match's
split rule is unconditionally pinned and compares the store's splitRatio
against status.tensor_split, and applyInferenceStatusToStore clears splitRatio
unless the mode is manual. So an auto tensor-parallel server now compares a
field the applier cleared against a ratio the planner legitimately chose,
disagrees, and declines to adopt a resident model that is exactly what was
asked for. That is the reload loop from the backend fix reappearing one layer
up, reached through the UI rather than the API.

Judged on the mode the RESIDENT server ran, not the one this pick would send:
the config's own mode cannot tell a manual load whose custom ratio the config
forgot from an auto load carrying the planner's. A server too old to report
gpu_memory_mode is still compared, so nothing that used to reload stops
reloading, and the existing custom-split case still reloads.
@danielhanchen

Copy link
Copy Markdown
Member

Reviewed and tested 72bb8dd6. The approach is right: keeping _auto_tensor_split_emitted separate leaves self._tensor_split alone, which is what the manual-mode reload comparison, the arch gate and the shared-heap VRAM accounting all read, so none of them change behaviour. Resets are wired at all four sites. 361 backend tests across the placement, tensor-parallel, status and simulation suites pass on it.

One thing it does reach, which I have fixed in 4d296b5f.

resident-config-match.ts has an unconditionally pinned rule comparing the store's splitRatio against status.tensor_split, and applyInferenceStatusToStore clears splitRatio unless the mode is manual. That rule was written when auto could only ever report null. Now that auto reports a ratio, it compares a field the applier cleared against a ratio the planner legitimately chose, disagrees, and declines to adopt a resident model that is exactly what was asked for. That is the reload loop from the backend fix reappearing one layer up, reached through the UI rather than the API:

status { gpu_memory_mode: "auto", tensor_parallel: true, tensor_split: [0.75, 0.25] }
config { tensorParallel: true }
residentRuntimeMatchesConfig -> false   (before 4d296b5f)
                             -> true    (after)

Judged on the mode the resident server ran, not the one the pick would send. The config's own mode cannot tell a manual load whose custom ratio the config forgot from an auto load carrying the planner's, and a server too old to report gpu_memory_mode is still compared, so nothing that used to reload stops reloading. The existing "a custom tensor split the config cannot carry is still a reload" case still passes. resident-config-match.test.ts 106 pass, resident-config-match-accelerator-matrix.test.ts 250 pass, and the new test fails without the source change.

Worth noting for whoever reviews next: the /status change is the reason a frontend file is now in this PR at all, and it is the second time this ratio has been compared against something in different units. If a third consumer appears it is probably worth a single helper that says what a ratio means on each side.

@chakshu-dhannawat

Copy link
Copy Markdown
Contributor Author

Pulled your frontend fix and re-ran everything:\n\n- Backend: 236 placement + simulation tests pass, ruff clean.\n- Frontend: 356 resident-config-match tests pass.\n\nThanks for catching the resident-config-match loop. Keeping _auto_tensor_split_emitted separate from _tensor_split looks like the right call since it avoids touching manual dedupe and VRAM accounting.

…t this one

The sibling simulation file varies the ratio, the card count and the planner's
answer, all on whatever host happens to run it. This varies the host: the
Cartesian product of {Linux, Windows, WSL, macOS} x {NVIDIA, AMD ROCm, AMD or
Intel via Vulkan, CPU only}. The code the fallback sits in reads sys.platform,
_is_wsl and torch's ROCm markers on the way to a launch, and the crash it used
to cause was first seen on the Vulkan route, so a table that only ever asks
one host is not evidence about the others.

Simulated at the level the production code actually reads: sys.platform and
_is_wsl for the OS, a fake torch carrying or not carrying version.hip for the
vendor (which is exactly what _host_torch_is_rocm looks at), and the
(index, free, total) rows _get_gpu_memory returns for the cards. No torch is
imported, so the whole file runs in a bare venv in about five seconds.

The failed-plan case uses three cards with one below the tensor-parallel
compute-buffer reserve, because that is what makes the recovery arm's rebuilt
gpu_indices wider than the tp_gpus the planner filtered. Written first with two
equal cards, where it passed against the unfixed revision and proved nothing;
with the third card it fails on all twelve GPU hosts there and passes here.

The device-pin case asserts the AGREEMENT between the ratio and the pin rather
than a spelling, since each vendor pins through a different channel and this
change touches none of them: NVIDIA masks with CUDA_VISIBLE_DEVICES, ROCm on
Linux and WSL masks at the ROCr layer and re-indexes the CUDA mask, Windows has
no ROCr so it keeps the HIP mask, and Vulkan does not mask at all but pins
--device VulkanN.

Stated in the file, and worth repeating: this proves the placement decision,
the argv and the mask on each host. It does not prove a real ROCm or Metal
driver then behaves, and sys.platform cannot make the stdlib's Windows paths
work on Linux, which is why the reuse case stubs the capability probe. The real
Windows and macOS answers are the runners; the real GPU answer is the T4s.
The change alters what a load does with a tensor_split it is handed, so the
question for an install that already exists is narrower: can anything already
on its disk start handing it one? Saved per-model settings are one JSON row in
app_settings, and the only things that reach a load from there are what
normalize_model_override allow-lists in and what model_override_load_kwargs
emits out, so the upgrade question is answerable exactly and without a database.

It cannot. A ratio is not in the allow-list, and writing one into the row by
hand -- which is possible, it is JSON in sqlite -- is dropped rather than
forwarded. Measured against the merge base as well as asserted here: the same
saved override replays byte-identical load kwargs on both revisions.

Forwards compatibility is the same argument backwards. Nothing new is
persisted, so a row written by this version is what an older one already
understands, and the normalizer is idempotent, so an install that upgrades and
rolls back does not find its settings rewritten.
@danielhanchen

Copy link
Copy Markdown
Member

Confirmed against llama_cpp.py, where auto tensor-parallel emits --split-mode tensor but drops the user ratio when the planner returns nothing, and the follow-up budget validation plus reload matching both look right. Will get this reviewed.

@danielhanchen

Copy link
Copy Markdown
Member

Closing out the coverage gaps I left open earlier. Three additions, all pushed.

1. An isolated sandbox, and it needs no torch. The whole simulation set runs in a bare uv venv with pytest, pydantic, fastapi, httpx, structlog and huggingface_hub and nothing else. core.inference.llama_cpp imports fine without torch, so 412 tests run in 44 seconds instead of pulling a multi-gigabyte wheel. Worth knowing for anyone iterating on placement.

2. Every supported host, not just the one running the tests. test_pr10884_platform_vendor_matrix.py is the Cartesian product of {Linux, Windows, WSL, macOS} x {NVIDIA, AMD ROCm, AMD or Intel via Vulkan, CPU only}, simulated at the level the code actually reads: sys.platform and _is_wsl for the OS, a fake torch carrying or not carrying version.hip for the vendor (exactly what _host_torch_is_rocm looks at), and the (index, free, total) rows _get_gpu_memory returns for the cards.

The first version of the crash case used two equal cards and passed against 084fa1f8, proving nothing. Three cards with one below the tensor-parallel compute-buffer reserve is what makes the recovery arm's rebuilt gpu_indices wider than the tp_gpus the planner filtered:

084fa1f8   12 failed, 44 passed    (all twelve GPU hosts raise)
28d6cc01   56 passed

It also pins that the ratio and the device pin agree on each host, since each vendor pins through a different channel and this change touches none of them: NVIDIA masks with CUDA_VISIBLE_DEVICES, ROCm on Linux and WSL masks at the ROCr layer and re-indexes the CUDA mask, Windows has no ROCr so it keeps the HIP mask, and Vulkan does not mask at all but pins --device VulkanN.

3. What an existing install does on its first load after updating. Answerable exactly, because saved per-model settings are one JSON row and the only things that reach a load from there are what normalize_model_override allow-lists in and what model_override_load_kwargs emits out. A ratio is not in the allow-list, and writing one into the row by hand is dropped rather than forwarded. Measured against the merge base, not just asserted: the same saved override replays byte-identical load kwargs on both revisions.

On browsers

resident-config-match.ts is the only frontend file in this PR, and it contains no DOM call, no window, no fetch, no Intl and no storage access. Its imports are two types and one sibling module, and it compares plain JavaScript values with ===, ?? and Array.prototype.every. There is no engine-dependent surface in it. The repo's own studio-ui-smoke.yml has a shard that runs chromium, firefox and webkit, which covers Chrome and Edge, Firefox, and Safari respectively, and it is queued against the staged branch now along with the Windows UI job.

What is still outstanding

The re-staged cross-platform run on 28d6cc01 is queued and has not started; all three staging repos are saturated. The green five-platform result I posted earlier was f7b93a0a, which is the backend fix but predates the /status change, my frontend predicate fix and these two test files. I am not claiming that result for the current head. What is verified locally on the current head is 412 tests green in the sandbox, and 41,258 backend tests with no regression against the merge base as of 227bfc05.

The argv is ground truth but a user cannot read /proc, and for the whole life
of the auto path the status property returned the manual-mode field, which auto
never writes -- so a server launched with --tensor-split 3,1 reported
tensor_split: null and no client could tell a forwarded ratio from a dropped
one. The GPU leg now reads both and fails when they disagree, which is what
makes the /status change answerable on two real cards rather than only in a
unit test.
@chakshu-dhannawat

Copy link
Copy Markdown
Contributor Author

Pulled the latest additions and ran them locally:\n\n- test_llama_cpp_placement.py + test_pr10884_auto_tensor_split_sim.py + test_pr10884_platform_vendor_matrix.py + test_pr10884_upgrade_compat.py: 301 passed\n- resident-config-match tests: 356 passed\n- ruff check: clean\n\nGood to know the simulation set runs without torch. Let me know if the queued cross-platform run surfaces anything.

@danielhanchen

Copy link
Copy Markdown
Member

Re-ran the GPU leg on two real Tesla T4s against the current head e2bbd7e2, so the earlier green result is no longer the stale one. Kernel unsloth-t4-ci-573d6289, all 15 assertions pass, CUDA 13 llama.cpp, base and head in one session with only studio/backend/core/inference/llama_cpp.py swapped between them.

leg llama-server pid argv --tensor-split /status tensor_split
base 0d9952f8, ratio 3,1 2621 absent null
head, ratio 3,1 2730 3,1 [0.75, 0.25]
head, same request again 2730 (reused) 3,1 [0.75, 0.25]
head, ratio changed to 1,3 2769 (reloaded) 1,3 [0.25, 0.75]

The base leg still reproduces the defect, so the comparison is not void. gpu_inference, tool_calling, server_flags and compaction pass on the same session; the split server served a 101-token completion with finish_reason: stop and held memory on both cards.

The /status column is what 72bb8dd6 bought, and it is now checked on hardware rather than only in a unit test: the leg reads the live child's argv and the API's answer and fails if they disagree. Before that commit the property returned the manual-mode field, which the auto path never writes, so a server genuinely running 3,1 reported null and no client could tell a forwarded ratio from a dropped one. The base row above is that state.

This covers the head that includes the /status change, the resident-config-match fix and the two new test files, which is what the earlier five-platform run predated.

@chakshu-dhannawat

Copy link
Copy Markdown
Contributor Author

Great to see the T4 run confirm the API now matches the child's argv. Pulled e2bbd7e and the local suite still passes (301 backend tests, ruff clean).

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@danielhanchen

Copy link
Copy Markdown
Member

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T09:51:37.738970Z 2957ab0 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2bbd7e23b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +23450 to +23452
self._auto_tensor_split_emitted = self._auto_split_fingerprint(
_emitted_values
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the emitted ratio when recovery removes it

When an auto tensor-parallel startup hits the unsupported-GPU arch-crash retry, _without_tensor_split(cmd) removes --tensor-split from the retried command, but that path only clears the legacy _tensor_split field. This newly recorded _auto_tensor_split_emitted therefore survives and makes /status report a ratio the live child is not using; if recovery leaves one GPU, it can even report a split while tensor_parallel is false. Clear or recompute this field whenever a recovery rewrites the emitted split.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, and fixed in 102e6d6. Reproduced first: three cards with the third below the tensor-parallel reserve, so the planner pins two and the arch-crash retry has somewhere to narrow to. Before the fix the retry left the argv with no --tensor-split while the property answered [0.75, 0.25] with tensor_parallel false, exactly as described. The proactive arch gate is not reachable for this field, since its arm requires gpu_indices to be None and the auto fallback only emits when gpu_indices is truthy, so the clear goes on the reactive retry alone. The REQUESTED fingerprint is deliberately kept: the next Apply re-sends the same ratio and would be gated down here again, so reloading on it would rebuild the server on every Apply. Regression tests: test_the_arch_crash_retry_stops_reporting_the_ratio_it_dropped and its non-crashing control in studio/backend/tests/test_pr10884_auto_tensor_split_sim.py.

Comment on lines +7634 to +7638
rendered = _render(values)
if values and all(part == "0" for part in rendered):
peak = max(values)
if peak > 0:
rendered = _render(v / peak for v in values)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve ratios when only some tiny weights round to zero

For a valid ratio containing values on opposite sides of the six-decimal rounding threshold, such as [4e-7, 6e-7], this renders 0,0.000001. Because the rescaling branch runs only when every rendered part is zero, the requested 40:60 distribution becomes 0:100 and one GPU receives no model share. Rescale whenever a positive input is rounded to zero, or use a non-lossy decimal representation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not taking this one. The reading of the code is right: [4e-7, 6e-7] does render 0,0.000001, because the rescale only fires when every part renders zero. It is not reachable on real input, though. A tensor split is scale free by construction, llama.cpp normalizes the list before use, so any ratio a user can mean has an equivalent expression at any magnitude and only weights below 5e-7 lose anything here. The all-zero case is guarded because it is the one that makes llama.cpp divide by a zero total; a ratio whose largest weight is under a millionth is not a placement anyone asks for, and widening the rescale would change the emitted text for no input a session produces.

Comment on lines +445 to +446
agrees: (_c, s, standing) =>
s.gpu_memory_mode === "auto" || sameList(standing.splitRatio, s.tensor_split),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare pending auto-mode ratios before adopting

This unconditionally treats every auto-mode resident split as agreeable, but the outgoing load path sends standing.splitRatio even in auto mode and switching the GPU memory mode does not clear that store field. Thus, after setting a manual ratio and switching to Auto, selecting an already-resident auto model can skip /load even when the resident uses a different ratio, bypassing the backend's new auto-ratio matcher and silently discarding the pending placement change. Ignore the status split only when the pending ratio is actually absent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, and fixed in 102e6d6. Confirmed the premise in the code rather than by argument: applyInferenceStatusToStore keeps prevState.splitRatio whenever preserveSameModelEdits and gpuMemoryEditsPending hold, and gpuMemoryEditsPending is true precisely when the mode knob has been moved off the loaded mode, so a ratio set under Manual does survive the switch to Auto; and use-chat-model-runtime sends stateBeforeUnload.splitRatio with no mode gate. The rule now reads (s.gpu_memory_mode === 'auto' && standing.splitRatio == null) || sameList(...), so a cleared store still adopts a planner ratio while a pending one is still compared. Both directions are pinned in studio/frontend/tests/resident-config-match.test.ts; the reload case fails on the previous line and passes on this one.

…atio a reload

The arch-crash retry re-masks the child onto a narrowed device set and takes
--tensor-split off the argv, but only cleared the manual field, so the status
route kept answering with a ratio the live child did not have, and beside
tensor_parallel false when the narrowing left one GPU.

The picker's side of the same field: applyInferenceStatusToStore preserves a
pending splitRatio across a status apply, and the load path sends it in either
mode, so adopting on the resident mode alone discarded a placement change the
backend now honours in auto.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 14, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Sep 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 102e6d6cd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

scratch_cache_type_kv = _scratch_cache_type_kv,
)
):
_emitted_tensor_split = self._format_tensor_split(_sanitized_split)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve even splits for architectures that reject weights

When an auto tensor-parallel load of Gemma 3n (or another architecture that rejects weighted tensor splits) fits evenly and the user supplies a non-even ratio such as [3, 1], this fallback replaces the planner's deliberate None with a weighted --tensor-split. _plan_tensor_parallel explicitly leaves the split unset in this case because Gemma 3n can hit a GGML_ASSERT on weighted splits, so the first server startup now crashes and is downgraded to layer splitting instead of retaining the safe tensor-parallel launch; suppress the user fallback for architectures known not to support weighted splits.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not taking this one: the premise about the planner does not hold. _plan_tensor_parallel decides the split on capacity alone, if even_share_mib > (min_usable_mib - reserve_mib), with no architecture in the condition. The docstring line about Gemma 3n describes why the None case happens to be safe for every arch, not a rule the planner is enforcing, and the proof is the other branch: on uneven cards the planner emits a WEIGHTED split for Gemma 3n itself, so there is no arch guard here for this fallback to preserve. Manual mode has always forwarded a ratio for any architecture too, so suppressing it only in the auto fallback would make the same request behave differently in the two modes. The abort is already handled where it belongs and for all three paths: _should_record_tensor_split_abort catches the GGML_ASSERT, _record_tensor_split_abort memoizes it per (binary, model, cache pair), and the next load skips tensor mode up front and keeps the multi-GPU request on a layer split.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 102e6d6cd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +18038 to +18041
total_weight = sum(split)
for i, idx in enumerate(gpu_indices):
alloc_bytes = total_bytes * split[i] / total_weight
capacity_bytes = (usable_by_idx[idx] - reserve_mib) * 1024 * 1024

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reserve replicated context buffers before weighting

For an uneven user split at a large context, this treats the aggregate cc_bytes as though --tensor-split distributes it by the requested ratio. _plan_tensor_parallel documents and handles this buffer as replicated on every selected GPU, so a low-weight device is charged less than its mandatory full context buffer here. If that device has enough VRAM for the flat reserve but not the full context-linear buffer, the check accepts the ratio and launches tensor mode with --fit off, causing startup OOM. Subtract one per-device context-buffer allocation from every device's capacity, then weight only the remaining footprint.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, and fixed in 2957ab0. The check now subtracts one per-device context buffer from each card's capacity and weights only the remaining footprint, which is the planner's own arithmetic: its even-share gate compares (model + kv + mtp + cc_bytes)/n against min_usable - reserve, and since cc_bytes is n per-device buffers that is identical to charging the buffer flat, while its weighted branch subtracts cc_per_dev_mib from every card before ranking. Weighting the aggregate matched only at an even ratio. Worth recording that the error ran both ways: the low-weight card was under-charged as you describe, and the high-weight card was charged close to twice the buffer, so a ratio the planner itself would have accepted was refused, which is the original complaint again. The new cell test_the_context_buffer_is_charged_flat_not_by_the_ratio pins the 1:9 case that the aggregate form refused and the planner accepts; it fails on the previous revision.

…heck

Every device allocates the whole context-linear compute buffer whatever weight
it carries, which is why the planner subtracts one per-device slice from each
card before weighting. Distributing the aggregate by the ratio is the same
arithmetic only at an even share: away from it the low-weight card is charged
less than the buffer it must allocate and the high-weight card nearly twice it,
so a ratio the planner's own rule accepts was refused.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 2957ab0b8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] It ignores --tensor-split

2 participants