{"record":{"id":"f4a568f7da91d8b1","repo":"unslothai/unsloth","slug":"save-steps-save-total-limit-must-be-whole-number","errorCode":null,"errorMessage":"save_steps / save_total_limit must be whole numbers, got {self.save_steps!r} / {self.save_total_limit!r}","messagePattern":"save_steps / save_total_limit must be whole numbers, got (.+?) / (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"studio/backend/core/training/diffusion_train_common.py","lineNumber":1057,"sourceCode":"        # Refuse fp16 for a bf16-only DiT family up front, before evicting resident models.\n        if self.mixed_precision == \"fp16\" and resolved_family in _FORCE_BF16_FAMILIES:\n            raise ValueError(\n                f\"'{resolved_family}' LoRA training requires bf16: fp16 overflows its fp32 \"\n                f\"RoPE / embedder internals. Set mixed precision to bf16.\"\n            )\n        if str(self.lr_scheduler) not in _LR_SCHEDULERS:\n            raise ValueError(\n                f\"lr_scheduler must be one of {', '.join(sorted(_LR_SCHEDULERS))}; \"\n                f\"got {self.lr_scheduler!r}\"\n            )\n        if not 1 <= int(self.cache_variants) <= 16:\n            raise ValueError(\"cache_variants must be between 1 and 16\")\n        # Checkpointing knobs. Rejected here, before the route evicts resident GPU models, rather than deep in the loop.\n        try:\n            save_steps = int(self.save_steps or 0)\n            save_total_limit = int(self.save_total_limit or 0)\n        except (TypeError, ValueError) as exc:\n            raise ValueError(\n                f\"save_steps / save_total_limit must be whole numbers, got \"\n                f\"{self.save_steps!r} / {self.save_total_limit!r}\"\n            ) from exc\n        if save_steps < 0:\n            raise ValueError(\"save_steps must be >= 0 (0 disables periodic checkpoints)\")\n        if save_total_limit < 0:\n            raise ValueError(\"save_total_limit must be >= 0 (0 keeps every checkpoint)\")\n        # A blank resume path (the Studio default when the field is present but unset) means \"fresh run\", not the outputs root.\n        resume_from_checkpoint = (\n            str(self.resume_from_checkpoint).strip()\n            if self.resume_from_checkpoint is not None\n            else \"\"\n        ) or None\n        # The H3 loop does not checkpoint: it neither writes a resume bundle nor restores one.\n        # Accepting these two silently was the dangerous part -- a caller handing over a resume\n        # bundle got a FRESH optimization that then overwrote the outputs it was meant to\n        # continue, and one asking for periodic saves got none, both discovered only after an\n        # expensive run. Refuse in validation, where it costs nothing, until the loop supports it.","sourceCodeStart":1039,"sourceCodeEnd":1075,"githubUrl":"https://github.com/unslothai/unsloth/blob/203007d19051dcd2ae33876786d117c99f6b0368/studio/backend/core/training/diffusion_train_common.py#L1039-L1075","documentation":"Raised when save_steps or save_total_limit cannot be converted with int(): the validator wraps the conversion in try/except (TypeError, ValueError) and re-raises with both offending values echoed. These checkpointing knobs arrive through the Studio config path where blanks/strings are common, so '' or 'abc' or None-adjacent junk lands here rather than crashing int() with a bare traceback.","triggerScenarios":"Passing save_steps='' (blank string from an unset form field), 'every 500', 500.5, [500], or None handling that bypasses the `or 0` default (only falsy values get defaulted — a truthy non-numeric string reaches int()). One bad value of the pair fails both, since they are validated together.","commonSituations":"Studio UI submits the field present-but-blank; hand-written YAML quoting numbers as prose ('500 steps'); values forwarded from another tool's JSON where the field is an object or list.","solutions":["Send integers (or omitted/None) for save_steps and save_total_limit — e.g. save_steps=500, save_total_limit=3.","If building the payload from a form, convert blank strings to None before submission so the `or 0` default applies.","The error echoes both values; fix whichever one shows as non-numeric in the message."],"exampleFix":"# before\nconfig = TrainConfig(save_steps='500 steps')\n\n# after\nconfig = TrainConfig(save_steps=500)","handlingStrategy":"validation","validationCode":"def check_checkpoint_knobs(save_steps, save_total_limit) -> tuple[int, int]:\n    def as_int(v, default=0):\n        if v in (None, \"\"):\n            return default\n        return int(v)  # raises for junk like '500 steps' or lists\n    return as_int(save_steps), as_int(save_total_limit)","typeGuard":"def are_valid_checkpoint_knobs(save_steps, save_total_limit) -> bool:\n    for v in (save_steps, save_total_limit):\n        if v in (None, \"\"):\n            continue\n        try:\n            int(v)\n        except (TypeError, ValueError):\n            return False\n    return True","tryCatchPattern":"try:\n    session.submit_training(config)\nexcept ValueError as e:\n    if \"save_steps / save_total_limit\" in str(e):\n        config.save_steps, config.save_total_limit = None, None  # fall back to defaults\n        session.submit_training(config)\n    else:\n        raise","preventionTips":["Send ints (or None) for both checkpoint knobs; convert blank form strings to None at your boundary.","Remember one bad value of the pair fails both — the message echoes both reprs, so inspect it.","Keep numbers unquoted and unit-free in YAML/JSON ('500', not '500 steps')."],"tags":["training","checkpointing","configuration","validation","type-coercion"],"backgroundTag":null,"analyzedSha":"203007d19051dcd2ae33876786d117c99f6b0368","analyzedAt":"2026-08-15T02:48:39.846Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}