feat(studio): Mac-aware training controls for MLX (optimizers, LoftQ, packing) (#7358)

* feat(studio): offer MLX-supported optimizers on Apple Silicon

The training form's optimizer dropdown only listed CUDA/bitsandbytes optimizers (adamw_8bit, paged variants, torch fused). On Apple Silicon the MLX trainer supports a different set (adamw, adam, lion, muon, sgd, adafactor) and remaps every bitsandbytes/torch name to plain AdamW, so the dropdown misrepresented what actually runs.

Offer the MLX optimizer list when the device is a Mac, and derive the displayed value so the control is never blank: the shared CUDA default and the other bitsandbytes/torch options render as AdamW (exactly how the MLX backend normalizes them), while any other value is shown as-is so an unrecognized or non-canonical imported optimizer is never mislabeled. Non-Mac behavior is unchanged. The run-summary optimizer label now resolves from both lists.

* feat(studio): show an MLX-appropriate optimizer tooltip on Apple Silicon

The optimizer tooltip described "8-bit variants" and recommended "Fused" for vision models, neither of which is offered when training runs on MLX. On Apple Silicon, show a tooltip that matches the MLX optimizer set and notes that Lion typically needs a lower learning rate than AdamW.

Copy-only: no change to the selected optimizer or the learning rate, and the non-Mac tooltip is unchanged. The new string is added to the English locale; other locales fall back to English until translated, matching how new keys are handled elsewhere.

* fix(studio): label Mac CUDA-alias optimizers as AdamW in the run summary

On Apple Silicon the run-configuration summary looked up the stored optimizer name directly, so a run that kept a CUDA/bitsandbytes default such as adamw_8bit was labeled "AdamW 8-bit" even though the picker shows "AdamW" and the MLX backend runs plain AdamW. Mirror the training form's derivation so those aliases are labeled AdamW in the summary too.

Display-only: no change to the stored or submitted optimizer, and non-Mac summaries are unchanged.

* feat(studio): disable LoftQ and sequence packing on Apple Silicon

Neither LoftQ nor sequence packing is supported on MLX — the backend rejects LoftQ and the trainer silently forces packing off — yet the training form still offered both on Apple Silicon.

Disable the LoftQ LoRA-init option (greyed and unclickable, with an inline "Not supported on Apple Silicon" note) and the "Enable packing" checkbox (greyed, with a tooltip explaining why), matching how the unsupported "Enable streaming" control is presented. Clearing effects reset a stale loftq/packing value to its default on Mac so the disabled controls never submit it. Non-Mac behavior is unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Long Yixing 2026-07-23 17:14:20 +08:00 committed by GitHub
commit e143e1ce33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 115 additions and 26 deletions

View file

@ -92,6 +92,19 @@ export const OPTIMIZER_OPTIONS: ReadonlyArray<{ value: string; label: string }>
{ value: "adamw_torch_fused", label: "AdamW (PyTorch Fused)" },
];
// Optimizers the MLX trainer actually supports on Apple Silicon. Values must
// match SUPPORTED_MLX_OPTIMIZERS in unsloth-zoo's mlx/trainer.py; on MLX the
// bitsandbytes/torch names above have no meaning and are remapped to plain
// AdamW, so Studio offers this list instead when running on a Mac.
export const MLX_OPTIMIZER_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: "adamw", label: "AdamW" },
{ value: "adam", label: "Adam" },
{ value: "lion", label: "Lion" },
{ value: "muon", label: "Muon" },
{ value: "sgd", label: "SGD" },
{ value: "adafactor", label: "Adafactor" },
];
export const LR_SCHEDULER_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: "linear", label: "Linear" },
{ value: "cosine", label: "Cosine" },

View file

@ -36,6 +36,7 @@ import {
CONTEXT_LENGTHS,
CPT_TARGET_MODULES,
LR_SCHEDULER_OPTIONS,
MLX_OPTIMIZER_OPTIONS,
OPTIMIZER_OPTIONS,
TARGET_MODULES,
} from "@/config/training";
@ -204,6 +205,42 @@ export function ParamsSection(): ReactElement {
setCtxInput(String(store.contextLength));
}, [store.contextLength]);
// On Apple Silicon the MLX trainer supports a different optimizer set than
// the CUDA/bitsandbytes list, so offer the MLX names there.
const isMac = platformDeviceType === "mac";
const optimizerOptions = isMac ? MLX_OPTIMIZER_OPTIONS : OPTIMIZER_OPTIONS;
// On Mac, the MLX backend normalizes every CUDA/bitsandbytes optimizer in
// OPTIMIZER_OPTIONS (including the shared default) to plain AdamW, so show
// AdamW for those to keep the control truthful and non-blank. Any other
// value -- an MLX optimizer the user picked, or an unrecognized/non-canonical
// imported one -- is shown as-is rather than mislabeled as AdamW, since the
// backend would run or reject it on its own terms. Non-Mac display unchanged.
const isCudaAliasOptimizer = OPTIMIZER_OPTIONS.some(
(o) => o.value === store.optimizerType,
);
const selectedOptimizer =
isMac && isCudaAliasOptimizer ? "adamw" : store.optimizerType;
// LoftQ is not supported on MLX (the backend rejects it), so clear a stale
// selection to lora on Apple Silicon -- whether persisted, applied from a
// model default, or imported -- so the backend never receives it.
const setLoraVariant = store.setLoraVariant;
useEffect(() => {
if (isMac && store.loraVariant === "loftq") {
setLoraVariant("lora");
}
}, [isMac, store.loraVariant, setLoraVariant]);
// Packing is not supported on MLX (the backend forces it off), so clear it on
// Apple Silicon -- the checkbox is disabled and the flag is never sent.
const setPacking = store.setPacking;
useEffect(() => {
if (isMac && store.packing) {
setPacking(false);
}
}, [isMac, store.packing, setPacking]);
const trySetContextLength = (input: string): number | null => {
const n = Number(input);
if (Number.isInteger(n) && n > 0) {
@ -706,8 +743,9 @@ export function ParamsSection(): ReactElement {
<button
key={opt.value}
type="button"
disabled={isMac && opt.value === "loftq"}
onClick={() => store.setLoraVariant(opt.value)}
className={`flex-1 corner-squircle rounded-xl border px-3 py-2 text-left transition-colors cursor-pointer ${
className={`flex-1 corner-squircle rounded-xl border px-3 py-2 text-left transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-60 ${
store.loraVariant === opt.value
? "border-ring-strong bg-primary/5"
: "border-border hover:border-foreground/20"
@ -715,7 +753,9 @@ export function ParamsSection(): ReactElement {
>
<p className="text-xs font-medium">{opt.label}</p>
<p className="text-ui-10 text-muted-foreground">
{opt.desc}
{isMac && opt.value === "loftq"
? "Not supported on Apple Silicon"
: opt.desc}
</p>
</button>
))}
@ -765,7 +805,11 @@ export function ParamsSection(): ReactElement {
label={t("studio.params.optimizer")}
tooltip={
<>
{t("studio.params.optimizerTooltip")}{" "}
{t(
isMac
? "studio.params.optimizerTooltipMlx"
: "studio.params.optimizerTooltip",
)}{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
target="_blank"
@ -778,14 +822,14 @@ export function ParamsSection(): ReactElement {
}
>
<Select
value={store.optimizerType}
value={selectedOptimizer}
onValueChange={(v) => store.setOptimizerType(v)}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPTIMIZER_OPTIONS.map((opt) => (
{optimizerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{formatOptimizerLabel(opt.value, opt.label, t)}
</SelectItem>
@ -1105,14 +1149,37 @@ export function ParamsSection(): ReactElement {
<Checkbox
id="packing"
checked={store.packing}
disabled={isMac}
onCheckedChange={(v) => store.setPacking(!!v)}
/>
<label
htmlFor="packing"
className="text-xs cursor-pointer text-muted-foreground"
className={`text-xs text-muted-foreground ${
isMac
? "cursor-not-allowed opacity-60"
: "cursor-pointer"
}`}
>
{t("studio.params.enablePacking")}
</label>
{isMac && (
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Packing is not supported on Apple Silicon (MLX).
</TooltipContent>
</Tooltip>
)}
</div>
)}
{!store.isEmbeddingModel && !isCpt && !isRawText && (

View file

@ -19,7 +19,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { OPTIMIZER_OPTIONS } from "@/config/training";
import { usePlatformStore } from "@/config/env";
import { MLX_OPTIMIZER_OPTIONS, OPTIMIZER_OPTIONS } from "@/config/training";
import { setTrainingCompareHandoff } from "@/features/chat";
import {
useTrainingActions,
@ -92,6 +93,7 @@ export function ProgressSection({
}: ProgressSectionProps): ReactElement {
const t = useT();
const navigate = useNavigate();
const platformDeviceType = usePlatformStore((s) => s.deviceType);
const trainingMethodLabel = getTrainingMethodLabel(data.trainingMethod);
const config = useTrainingConfigStore(
@ -187,9 +189,18 @@ export function ProgressSection({
const cfgLoraDropout = cfg?.loraDropout;
const cfgLoraVariant = cfg?.loraVariant;
// Mirror the training form: on Mac the CUDA/bitsandbytes optimizer names run
// as plain AdamW (the MLX backend normalizes them), so label them AdamW here
// too rather than by the requested, unnormalized name.
const effectiveOptimizer =
platformDeviceType === "mac" &&
OPTIMIZER_OPTIONS.some((o) => o.value === cfgOptimizerType)
? "adamw"
: cfgOptimizerType;
const optimizerLabel =
OPTIMIZER_OPTIONS.find((o) => o.value === cfgOptimizerType)?.label ??
cfgOptimizerType;
[...OPTIMIZER_OPTIONS, ...MLX_OPTIMIZER_OPTIONS].find(
(o) => o.value === effectiveOptimizer,
)?.label ?? effectiveOptimizer;
const configItems: ConfigGroup[] = [
{

View file

@ -1051,6 +1051,8 @@ export const en = {
optimizer: "Optimizer",
optimizerTooltip:
"Optimization algorithm. 8-bit variants reduce memory usage. Fused is recommended for vision models.",
optimizerTooltipMlx:
"Optimization algorithm. AdamW is the default. Lion uses less memory but usually needs a lower learning rate.",
lrScheduler: "LR scheduler",
lrSchedulerTooltip:
"How the learning rate changes over training. Linear decays steadily; cosine decays in a curve.",

View file

@ -38,7 +38,11 @@ def fail(m):
raise AssertionError(f"[font-scale] FAIL: {m}")
def near(a, b, tol = 0.35):
def near(
a,
b,
tol = 0.35,
):
return a is not None and b is not None and abs(a - b) <= tol
@ -86,9 +90,7 @@ def open_appearance(page):
page.wait_for_timeout(700)
if page.get_by_role("dialog").count() == 0:
fail("settings dialog did not open")
page.get_by_role("dialog").get_by_role("button").filter(
has_text = "Appearance"
).first.click()
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click()
page.wait_for_timeout(600)
@ -153,9 +155,7 @@ def main():
page.wait_for_timeout(400)
step("overflowing select scrolls its Radix viewport")
page.get_by_role("dialog").get_by_role("button").filter(
has_text = "Voice"
).first.click()
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Voice").first.click()
page.wait_for_timeout(600)
page.set_viewport_size({"width": 1440, "height": 480})
page.locator("[aria-label='Dictation language']").click()
@ -194,9 +194,7 @@ def main():
page.wait_for_timeout(400)
step("default restores exactly")
page.get_by_role("dialog").get_by_role("button").filter(
has_text = "Appearance"
).first.click()
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click()
page.wait_for_timeout(500)
set_input(page, "UI font size", DEFAULT)
final = measure(page)

View file

@ -15,9 +15,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SRC = REPO / "studio/frontend/src"
INDEX_CSS = (SRC / "index.css").read_text(encoding = "utf-8")
STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text(
encoding = "utf-8"
)
STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text(encoding = "utf-8")
SELECT = (SRC / "components/ui/select.tsx").read_text(encoding = "utf-8")
# Raw numeric fontSize props are only allowed where a scaled stylesheet rule
@ -70,9 +68,7 @@ def test_ui_token_families_exist():
def test_explicit_code_font_size_is_never_multiplied():
match = re.search(
r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS
)
match = re.search(r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS)
assert match is not None
body = match.group(1)
assert "var(--custom-code-font-size)" in body
@ -83,7 +79,9 @@ def test_radix_select_viewport_owns_the_scroll_state():
viewport = SELECT[SELECT.index("SelectPrimitive.Viewport") :]
assert "overflow-y-auto" in viewport.split("</SelectPrimitive.Viewport>")[0]
# The rounded surface itself must not scroll (WebKit squares its corners).
content_cls = re.search(r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT)
content_cls = re.search(
r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT
)
assert content_cls is not None
assert "overflow-hidden" in content_cls.group(1)
assert "overflow-y-auto" not in content_cls.group(1)