From 862b4100d2c1e5eb84da4bf034d4aa79bd7ac343 Mon Sep 17 00:00:00 2001 From: samit Date: Fri, 27 Feb 2026 06:00:28 -0800 Subject: [PATCH 01/33] deleted duplicate definitions --- studio/backend/core/inference/inference.py | 131 --------------------- 1 file changed, 131 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 1147c281b7..329f5d944b 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -268,47 +268,6 @@ class InferenceBackend: logger.error(traceback.format_exc()) return False, None - def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str = None) -> bool: - """ - Load a LoRA adapter onto the base model if it's not already registered. - This method is idempotent. - """ - if base_model_name not in self.models: - logger.error(f"Base model {base_model_name} not loaded") - return False - - model = self.models[base_model_name].get("model") - if model is None: - logger.error(f"Model object for {base_model_name} is None.") - return False - - if adapter_name is None: - adapter_name = adapter_path.split("/")[-1].replace(".", "_") - - # If we've loaded this adapter before, we don't need to do anything. - if adapter_name in self.models[base_model_name].get("loaded_adapters", {}): - logger.info(f"Adapter '{adapter_name}' is already registered. Skipping.") - return True - - try: - logger.info(f"Loading new adapter '{adapter_name}' from '{adapter_path}' onto {base_model_name}") - - # Unsloth modifies the model in-place and returns None. Do NOT re-assign. - model.load_adapter(adapter_path, adapter_name=adapter_name) - - # Update our internal registry so we don't load it again. - self.models[base_model_name]["loaded_adapters"][adapter_name] = adapter_path - - total_adapters = len(getattr(model, 'peft_config', {})) - logger.info(f"Adapter '{adapter_name}' loaded successfully. (Total adapters on model: {total_adapters})") - return True - except Exception as e: - logger.error(f"Failed to load adapter '{adapter_name}': {e}") - import traceback - logger.error(traceback.format_exc()) - return False - pass - def enable_adapter(self, base_model_name: str, adapter_name: str) -> bool: """Enable specific adapter (for generation)""" if base_model_name not in self.models: @@ -341,55 +300,6 @@ class InferenceBackend: logger.error(f"Failed to disable adapters: {e}") return False - # In backend/inference.py - - def load_for_eval(self, lora_path: str, max_seq_length: int = 2048, - dtype = None, load_in_4bit: bool = True, - hf_token: Optional[str] = None) -> Tuple[bool, Optional[str], Optional[str]]: - """ - Prepare for eval: ensure base model and the specified adapter are loaded. - """ - try: - from utils.models import ModelConfig - lora_config = ModelConfig.from_lora_path(lora_path, hf_token) - if not lora_config: - return False, None, None - - base_model_name = lora_config.base_model - - # 1. Load the base model if it's not already in memory (this logic is correct) - if base_model_name not in self.models or not self.models[base_model_name].get("model"): - logger.info(f"Base model '{base_model_name}' not loaded, loading now.") - base_config = ModelConfig.from_ui_selection(base_model_name, None, is_lora=False) - if not self.load_model(base_config, max_seq_length, dtype, load_in_4bit, hf_token): - return False, None, None - else: - logger.info(f"Base model '{base_model_name}' is already in memory.") - - self.active_model_name = base_model_name - - # 2. Delegate to our now-idempotent load_adapter function. - # It will handle all cases: first adapter, or subsequent adapters. - adapter_name = lora_path.split("/")[-1].replace(".", "_") - adapter_success = self.load_adapter( - base_model_name=base_model_name, - adapter_path=lora_path, - adapter_name=adapter_name - ) - - if not adapter_success: - return False, base_model_name, None - - return True, base_model_name, adapter_name - - except Exception as e: - logger.error(f"Error during load_for_eval: {e}") - import traceback - logger.error(traceback.format_exc()) - return False, None, None - pass - - def load_for_eval(self, lora_path: str, max_seq_length: int = 2048, dtype = None, load_in_4bit: bool = True, hf_token: Optional[str] = None) -> Tuple[bool, Optional[str], Optional[str]]: @@ -1272,47 +1182,6 @@ class InferenceBackend: """Get name of currently loading model""" return next(iter(self.loading_models)) if self.loading_models else None - def load_model_simple(self, - model_path: str, - hf_token: Optional[str] = None, - max_seq_length: int = 2048, - load_in_4bit: bool = True) -> bool: - """ - Simple model loading wrapper for chat interface. - Accepts model path as string and handles ModelConfig creation internally. - - Args: - model_path: Model name or path (e.g., "unsloth/llama-3-8b") - hf_token: HuggingFace token for gated models - max_seq_length: Maximum sequence length - load_in_4bit: Whether to use 4-bit quantization - - Returns: - bool: True if successful, False otherwise - """ - try: - # Create config from string path - config = ModelConfig.from_ui_selection( - model_path, - lora_path=None, # No LoRA for chat - is_lora=False - ) - - # Call existing load_model with config - return self.load_model( - config=config, - max_seq_length=max_seq_length, - dtype=None, # Auto-detect - load_in_4bit=load_in_4bit, - hf_token=hf_token - ) - - except Exception as e: - logger.error(f"Error in load_model_simple: {e}") - return False - - - def load_model_simple(self, model_path: str, hf_token: Optional[str] = None, From d07397c81e3324d21094c3f678edb5904d61fe9f Mon Sep 17 00:00:00 2001 From: samit Date: Sat, 28 Feb 2026 01:17:43 -0800 Subject: [PATCH 02/33] added auth to dataset endpopints --- studio/backend/routes/datasets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/studio/backend/routes/datasets.py b/studio/backend/routes/datasets.py index cb1ea75e33..a53e223015 100644 --- a/studio/backend/routes/datasets.py +++ b/studio/backend/routes/datasets.py @@ -5,7 +5,7 @@ import base64 import io import sys from pathlib import Path -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException import logging # Add backend directory to path @@ -15,6 +15,7 @@ if str(backend_path) not in sys.path: # Import dataset utilities from utils.datasets import check_dataset_format +from auth.authentication import get_current_subject router = APIRouter() logger = logging.getLogger(__name__) @@ -84,7 +85,10 @@ DATA_EXTS = ( @router.post("/check-format", response_model=CheckFormatResponse) -def check_format(request: CheckFormatRequest): +def check_format( + request: CheckFormatRequest, + current_subject: str = Depends(get_current_subject), +): """ Check if a dataset requires manual column mapping. From ece51dca1381bcd17a948f3d8ab2445c1032ff96 Mon Sep 17 00:00:00 2001 From: samit Date: Sat, 28 Feb 2026 02:10:12 -0800 Subject: [PATCH 03/33] updated fetch to auth fetch in the frontend --- studio/frontend/src/features/training/api/datasets-api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/training/api/datasets-api.ts b/studio/frontend/src/features/training/api/datasets-api.ts index 7bba75ca38..b2348abed8 100644 --- a/studio/frontend/src/features/training/api/datasets-api.ts +++ b/studio/frontend/src/features/training/api/datasets-api.ts @@ -1,3 +1,4 @@ +import { authFetch } from "@/features/auth"; import type { CheckFormatResponse } from "../types/datasets"; type CheckDatasetFormatArgs = { @@ -15,7 +16,7 @@ export async function checkDatasetFormat({ split, isVlm, }: CheckDatasetFormatArgs): Promise { - const res = await fetch("/api/datasets/check-format", { + const res = await authFetch("/api/datasets/check-format", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ From 84bb57f208a789ec0d38f8b2812a4cdc510937ef Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Tue, 3 Mar 2026 18:37:46 +0000 Subject: [PATCH 04/33] fix: prevent select scroll-lock margin from shifting layout --- studio/frontend/src/components/ui/select.tsx | 36 ++++++++++---------- studio/frontend/src/index.css | 3 ++ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/studio/frontend/src/components/ui/select.tsx b/studio/frontend/src/components/ui/select.tsx index 52fe4e5a75..4c500af08e 100644 --- a/studio/frontend/src/components/ui/select.tsx +++ b/studio/frontend/src/components/ui/select.tsx @@ -4,8 +4,8 @@ import { Select as SelectPrimitive } from "radix-ui"; import type * as React from "react"; import { createContext, useContext, useState } from "react"; -import { cn } from "@/lib/utils"; -import { useDialogPortalContainer } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { useDialogPortalContainer } from "@/components/ui/dialog"; import { ArrowDown01Icon, ArrowUp01Icon, @@ -92,22 +92,22 @@ function SelectTrigger({ ); } -function SelectContent({ - className, - children, - position = "item-aligned", - align = "center", - container, - ...props -}: React.ComponentProps & { - container?: HTMLElement | null; -}) { - const dialogContainer = useDialogPortalContainer(); - return ( - - & { + container?: HTMLElement | null; +}) { + const dialogContainer = useDialogPortalContainer(); + return ( + + Date: Tue, 3 Mar 2026 20:15:23 +0000 Subject: [PATCH 05/33] fix: sanitize dataset script errors and persist training start error --- .../hf-dataset-subset-split-selectors.tsx | 2 +- .../training/hooks/use-training-actions.ts | 22 ++++++++-- .../training/stores/training-runtime-store.ts | 1 - .../src/hooks/use-hf-dataset-splits.ts | 44 ++++++++++++++++++- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index d21fd55ff4..148341e4de 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -106,7 +106,7 @@ export function HfDatasetSubsetSplitSelectors({ : "rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400" } > - Could not fetch dataset splits: {error} + {error} )} diff --git a/studio/frontend/src/features/training/hooks/use-training-actions.ts b/studio/frontend/src/features/training/hooks/use-training-actions.ts index bfe035cf4a..1f3db35ce5 100644 --- a/studio/frontend/src/features/training/hooks/use-training-actions.ts +++ b/studio/frontend/src/features/training/hooks/use-training-actions.ts @@ -16,6 +16,19 @@ const ROLE_REMAP: Record> = { sharegpt: { user: "human", assistant: "gpt", system: "system" }, }; +function normalizeTrainingStartError(message: string): string { + const normalized = message.toLowerCase(); + const isLegacyDatasetScriptError = + normalized.includes("failed to check dataset format") && + normalized.includes("dataset scripts are no longer supported"); + + if (isLegacyDatasetScriptError) { + return "This Hub dataset relies on a legacy custom script and isn’t supported in this training flow."; + } + + return message; +} + export function useTrainingActions() { const isStarting = useTrainingRuntimeStore((state) => state.isStarting); const startError = useTrainingRuntimeStore((state) => state.startError); @@ -79,7 +92,9 @@ export function useTrainingActions() { const response = await startTraining(payload); if (response.status === "error") { - runtimeStore.setStartError(response.error || response.message); + const rawMessage = response.error || response.message; + const safeMessage = normalizeTrainingStartError(rawMessage); + runtimeStore.setStartError(safeMessage); runtimeStore.setStarting(false); return false; } @@ -88,9 +103,10 @@ export function useTrainingActions() { await syncTrainingRuntimeFromBackend(); return true; } catch (error) { - const message = + const rawMessage = error instanceof Error ? error.message : "Failed to start training"; - runtimeStore.setStartError(message); + const safeMessage = normalizeTrainingStartError(rawMessage); + runtimeStore.setStartError(safeMessage); runtimeStore.setStarting(false); return false; } diff --git a/studio/frontend/src/features/training/stores/training-runtime-store.ts b/studio/frontend/src/features/training/stores/training-runtime-store.ts index 7d5c86549b..41b0fcca07 100644 --- a/studio/frontend/src/features/training/stores/training-runtime-store.ts +++ b/studio/frontend/src/features/training/stores/training-runtime-store.ts @@ -187,7 +187,6 @@ export const useTrainingRuntimeStore = create()((set) => ( evalEnabled: payload.eval_enabled ?? state.evalEnabled, message: payload.message, error: payload.error, - startError: null, currentStep: typeof detailStep === "number" ? Math.max(detailStep, 0) : state.currentStep, totalSteps: diff --git a/studio/frontend/src/hooks/use-hf-dataset-splits.ts b/studio/frontend/src/hooks/use-hf-dataset-splits.ts index 7b8e4906ec..bc769b5b4b 100644 --- a/studio/frontend/src/hooks/use-hf-dataset-splits.ts +++ b/studio/frontend/src/hooks/use-hf-dataset-splits.ts @@ -35,6 +35,37 @@ export interface HfDatasetSplitsResult { const HF_SPLITS_API = "https://datasets-server.huggingface.co/splits"; +function normalizeDatasetSplitsError(message: string): string { + const normalized = message.toLowerCase(); + + // datasets-server returns technical script/runtime details for legacy datasets. + if ( + normalized.includes("dataset scripts are no longer supported") || + normalized.includes("runs arbitrary python code") || + normalized.includes(".py") + ) { + return "We can’t load subset/split options for this Hub dataset because it relies on a legacy custom script."; + } + + if ( + normalized.includes("unauthorized") || + normalized.includes("forbidden") || + normalized.includes("access token") || + normalized.includes("private") || + normalized.includes("gated") || + normalized.includes("401") || + normalized.includes("403") + ) { + return "Unable to load dataset splits. This dataset may be private or gated. Add a Hugging Face token with access and try again."; + } + + if (normalized.includes("not found") || normalized.includes("404")) { + return "Dataset not found. Check the dataset name and try again."; + } + + return "Unable to load dataset split options for this dataset."; +} + // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -101,7 +132,18 @@ export function useHfDatasetSplits( }) .catch((err) => { if (!controller.signal.aborted) { - setError(err.message || "Failed to fetch dataset splits"); + const rawErrorMessage = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "Failed to fetch dataset splits"; + console.warn("[useHfDatasetSplits] Failed to fetch dataset splits", { + datasetName, + message: rawErrorMessage, + error: err, + }); + setError(normalizeDatasetSplitsError(rawErrorMessage)); setEntries([]); } }) From 43bf599b33ee0637356247a7b7a26beeb6af0f45 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 06:13:47 +0000 Subject: [PATCH 06/33] Remove overly broad .py check from dataset error normalization --- studio/frontend/src/hooks/use-hf-dataset-splits.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/studio/frontend/src/hooks/use-hf-dataset-splits.ts b/studio/frontend/src/hooks/use-hf-dataset-splits.ts index bc769b5b4b..cda2fcbc34 100644 --- a/studio/frontend/src/hooks/use-hf-dataset-splits.ts +++ b/studio/frontend/src/hooks/use-hf-dataset-splits.ts @@ -41,8 +41,7 @@ function normalizeDatasetSplitsError(message: string): string { // datasets-server returns technical script/runtime details for legacy datasets. if ( normalized.includes("dataset scripts are no longer supported") || - normalized.includes("runs arbitrary python code") || - normalized.includes(".py") + normalized.includes("runs arbitrary python code") ) { return "We can’t load subset/split options for this Hub dataset because it relies on a legacy custom script."; } From 34fb9ec973e0dfde63441a1df1fa9739af80210e Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 06:42:37 +0000 Subject: [PATCH 07/33] fix: cast URL image columns to HF Image() type in VLM conversion --- studio/backend/utils/datasets/format_conversion.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index 6436e7a82a..4e6a6d9919 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -254,8 +254,15 @@ def convert_to_vlm_format( list: List of dicts with 'messages' field """ from PIL import Image + from datasets import Image as datasets_Image from .vlm_processing import generate_smart_vlm_instruction + # Cast string image columns (URLs or local paths) to HF Image() type + # so HuggingFace handles downloading, decoding, and caching transparently. + sample_value = next(iter(dataset))[image_column] + if isinstance(sample_value, str): + dataset = dataset.cast_column(image_column, datasets_Image()) + # Generate smart instruction if not provided if instruction is None: instruction_info = generate_smart_vlm_instruction( From 645d7d357a37761365cefea66bc969949f01417d Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 06:42:48 +0000 Subject: [PATCH 08/33] fix: abort training pipeline on dataset conversion failure --- studio/backend/core/training/trainer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index e4e7a474be..c3376c10a2 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -468,6 +468,14 @@ class UnslothTrainer: print("Stopped during dataset formatting\n") return None + # Abort if dataset formatting/conversion failed + if not dataset_info.get("success", True): + errors = dataset_info.get("errors", []) + error_msg = "; ".join(errors) if errors else "Dataset formatting failed" + logger.error(f"Dataset conversion failed: {error_msg}") + self._update_progress(error=error_msg) + return None + self._update_progress(status_message=f"Dataset formatted and ready for training") print(f"Dataset formatted successfully\n") From 6ba669c8eb22bd8e3d879cecfd2d5a5c4363fcf6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 07:39:35 +0000 Subject: [PATCH 09/33] test: add URL image loading comparison script --- studio/tests/test_url_image_loading.py | 132 +++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 studio/tests/test_url_image_loading.py diff --git a/studio/tests/test_url_image_loading.py b/studio/tests/test_url_image_loading.py new file mode 100644 index 0000000000..4899bab8e3 --- /dev/null +++ b/studio/tests/test_url_image_loading.py @@ -0,0 +1,132 @@ +""" +Reproduce: VLM URL image loading with HF datasets. +Tests cast_column(Image()) vs manual download approaches. +Dataset: google-research-datasets/conceptual_captions (subset: labeled) +""" +from datasets import load_dataset, Image as datasets_Image, Dataset +from PIL import Image as PILImage +from io import BytesIO +from itertools import islice +import time + +DATASET = "google-research-datasets/conceptual_captions" +SUBSET = "labeled" +SPLIT = "train" +N_SAMPLES = 20 # small slice for testing + +print("=" * 60) +print("Loading dataset (streaming, first N samples)...") +print("=" * 60) +ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) +rows = list(islice(ds, N_SAMPLES)) +dataset = Dataset.from_list(rows) + +print(f"Loaded {len(dataset)} samples") +print(f"Columns: {dataset.column_names}") +print(f"First image_url: {dataset[0]['image_url'][:100]}...") +print() + +# ─── Test 1: cast_column(Image()) — what we tried ─── +print("=" * 60) +print("TEST 1: cast_column(Image()) approach") +print("=" * 60) +try: + ds_cast = dataset.cast_column("image_url", datasets_Image()) + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(ds_cast): + try: + img = sample["image_url"] + if img is not None: + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + else: + print(f" [{i}] None returned") + fail += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED during iteration: {type(e).__name__}: {str(e)[:120]}") +print() + +# ─── Test 2: Manual download with requests.Session ─── +print("=" * 60) +print("TEST 2: requests.Session() approach") +print("=" * 60) +try: + import requests + session = requests.Session() + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(dataset): + url = sample["image_url"] + try: + resp = session.get(url, timeout=10) + resp.raise_for_status() + img = PILImage.open(BytesIO(resp.content)).convert("RGB") + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") +print() + +# ─── Test 3: urllib (stdlib) ─── +print("=" * 60) +print("TEST 3: urllib approach (stdlib)") +print("=" * 60) +try: + from urllib.request import urlopen, Request + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(dataset): + url = sample["image_url"] + try: + req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urlopen(req, timeout=10) as resp: + img = PILImage.open(BytesIO(resp.read())).convert("RGB") + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") +print() + +# ─── Test 4: fsspec directly with expand=True ─── +print("=" * 60) +print("TEST 4: fsspec.open() with expand=True") +print("=" * 60) +try: + import fsspec + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(dataset): + url = sample["image_url"] + try: + with fsspec.open(url, "rb", expand=True) as f: + img = PILImage.open(BytesIO(f.read())).convert("RGB") + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") + +print() +print("=" * 60) +print("DONE — compare success rates and timing above") +print("=" * 60) From 722744cf04c1d1e3d0efee227119c93dab916969 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 07:50:55 +0000 Subject: [PATCH 10/33] fix: use fsspec for URL image downloads with per-sample error handling --- .../utils/datasets/format_conversion.py | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index 4e6a6d9919..37bd3c103c 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -254,15 +254,8 @@ def convert_to_vlm_format( list: List of dicts with 'messages' field """ from PIL import Image - from datasets import Image as datasets_Image from .vlm_processing import generate_smart_vlm_instruction - # Cast string image columns (URLs or local paths) to HF Image() type - # so HuggingFace handles downloading, decoding, and caching transparently. - sample_value = next(iter(dataset))[image_column] - if isinstance(sample_value, str): - dataset = dataset.cast_column(image_column, datasets_Image()) - # Generate smart instruction if not provided if instruction is None: instruction_info = generate_smart_vlm_instruction( @@ -288,12 +281,17 @@ def convert_to_vlm_format( def _convert_single_sample(sample): """Convert a single sample to VLM format.""" - # Get image (might be PIL Image or path) + # Get image (might be PIL Image, local path, or URL) image_data = sample[image_column] - # Handle image paths if isinstance(image_data, str): - image_data = Image.open(image_data).convert("RGB") + if image_data.startswith(("http://", "https://")): + import fsspec + from io import BytesIO + with fsspec.open(image_data, "rb", expand=True) as f: + image_data = Image.open(BytesIO(f.read())).convert("RGB") + else: + image_data = Image.open(image_data).convert("RGB") # Get text text_data = sample[text_column] @@ -324,11 +322,35 @@ def convert_to_vlm_format( # Return dict with messages return {"messages": messages} - # Use list comprehension and return the LIST directly - print(f"🔄 Converting {len(dataset)} samples to VLM format...") - converted_list = [_convert_single_sample(sample) for sample in dataset] + # Convert samples, skipping any with broken/unreachable images + total = len(dataset) + print(f"🔄 Converting {total} samples to VLM format...") + converted_list = [] + failed_count = 0 + for sample in dataset: + try: + converted_list.append(_convert_single_sample(sample)) + except Exception as e: + failed_count += 1 - print(f"✅ Converted {len(converted_list)} samples") + if failed_count > 0: + fail_rate = failed_count / total + print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images") + + if fail_rate >= 0.3: + raise ValueError( + f"{fail_rate:.0%} of images failed to download ({failed_count}/{total}). " + "This dataset has too many broken or unreachable image URLs to be usable for training. " + "Consider using a dataset with embedded images instead." + ) + + if len(converted_list) == 0: + raise ValueError( + f"All {total} samples failed during VLM conversion — no usable images found. " + "This dataset may contain only image URLs that are no longer accessible." + ) + + print(f"✅ Converted {len(converted_list)}/{total} samples") # Return list, NOT Dataset return converted_list From 5ee9479e371915231570c08badd35bf431707677 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 08:05:40 +0000 Subject: [PATCH 11/33] fix: add early probe to fail fast on datasets with too many broken image URLs --- .../utils/datasets/format_conversion.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index 37bd3c103c..f9da8b1a1e 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -322,28 +322,42 @@ def convert_to_vlm_format( # Return dict with messages return {"messages": messages} - # Convert samples, skipping any with broken/unreachable images + # Convert samples, skipping any with broken/unreachable images. + # For URL-based datasets, check the first PROBE_SIZE samples early to + # fail fast if too many images are broken, before downloading millions. + PROBE_SIZE = 5000 + MAX_FAIL_RATE = 0.3 + total = len(dataset) + has_urls = isinstance(next(iter(dataset))[image_column], str) + probe_needed = has_urls and total > PROBE_SIZE + print(f"🔄 Converting {total} samples to VLM format...") converted_list = [] failed_count = 0 - for sample in dataset: + + for i, sample in enumerate(dataset): try: converted_list.append(_convert_single_sample(sample)) except Exception as e: failed_count += 1 + # Early exit check after probing the first batch + if probe_needed and (i + 1) == PROBE_SIZE: + fail_rate = failed_count / PROBE_SIZE + if fail_rate >= MAX_FAIL_RATE: + raise ValueError( + f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " + f"({failed_count}/{PROBE_SIZE}). " + "This dataset has too many broken or unreachable image URLs. " + "Consider using a dataset with embedded images instead." + ) + print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...") + if failed_count > 0: fail_rate = failed_count / total print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images") - if fail_rate >= 0.3: - raise ValueError( - f"{fail_rate:.0%} of images failed to download ({failed_count}/{total}). " - "This dataset has too many broken or unreachable image URLs to be usable for training. " - "Consider using a dataset with embedded images instead." - ) - if len(converted_list) == 0: raise ValueError( f"All {total} samples failed during VLM conversion — no usable images found. " From e4ec16296e3376684e9b6988728db3e1880c83e0 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 13:30:27 +0000 Subject: [PATCH 12/33] feat: add tqdm progress bar to VLM conversion and download benchmark test --- .../utils/datasets/format_conversion.py | 9 +++- studio/tests/test_url_download_benchmark.py | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 studio/tests/test_url_download_benchmark.py diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index f9da8b1a1e..e784e8e645 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -332,20 +332,26 @@ def convert_to_vlm_format( has_urls = isinstance(next(iter(dataset))[image_column], str) probe_needed = has_urls and total > PROBE_SIZE + from tqdm import tqdm + print(f"🔄 Converting {total} samples to VLM format...") converted_list = [] failed_count = 0 - for i, sample in enumerate(dataset): + pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample") + for i, sample in enumerate(pbar): try: converted_list.append(_convert_single_sample(sample)) except Exception as e: failed_count += 1 + pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False) + # Early exit check after probing the first batch if probe_needed and (i + 1) == PROBE_SIZE: fail_rate = failed_count / PROBE_SIZE if fail_rate >= MAX_FAIL_RATE: + pbar.close() raise ValueError( f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " f"({failed_count}/{PROBE_SIZE}). " @@ -353,6 +359,7 @@ def convert_to_vlm_format( "Consider using a dataset with embedded images instead." ) print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...") + pbar.close() if failed_count > 0: fail_rate = failed_count / total diff --git a/studio/tests/test_url_download_benchmark.py b/studio/tests/test_url_download_benchmark.py new file mode 100644 index 0000000000..e29a8e05b6 --- /dev/null +++ b/studio/tests/test_url_download_benchmark.py @@ -0,0 +1,54 @@ +""" +Benchmark: fsspec URL image download throughput at different dataset sizes. +Dataset: google-research-datasets/conceptual_captions (subset: labeled) + +Tests sizes: 100, 200, 300, 500, 1000, 1500, 2000 +Reports: time, success/fail rate, throughput (images/sec) +""" +from datasets import load_dataset, Dataset +from PIL import Image as PILImage +from io import BytesIO +from itertools import islice +import fsspec +import time + +DATASET = "google-research-datasets/conceptual_captions" +SUBSET = "labeled" +SPLIT = "train" +SIZES = [100, 200, 300, 500, 1000, 1500, 2000] + +# Load the max we need in one go +max_size = max(SIZES) +print(f"Loading {max_size} samples from {DATASET} (streaming)...") +ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) +rows = list(islice(ds, max_size)) +full_dataset = Dataset.from_list(rows) +print(f"Loaded {len(full_dataset)} samples") +print(f"Columns: {full_dataset.column_names}") +print() + +print(f"{'Size':>6} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7}") +print("-" * 55) + +for size in SIZES: + dataset = full_dataset.select(range(size)) + success, fail = 0, 0 + t0 = time.time() + + for sample in dataset: + url = sample["image_url"] + try: + with fsspec.open(url, "rb", expand=True) as f: + img = PILImage.open(BytesIO(f.read())).convert("RGB") + success += 1 + except Exception: + fail += 1 + + elapsed = time.time() - t0 + fail_pct = (fail / size) * 100 + throughput = success / elapsed if elapsed > 0 else 0 + + print(f"{size:>6} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s") + +print() +print("Done.") From 880633e42b90ab076ca36b8e34a8e202612226d6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 14:30:11 +0000 Subject: [PATCH 13/33] test: add parallel download benchmark with ThreadPoolExecutor --- studio/tests/test_url_parallel_benchmark.py | 79 +++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 studio/tests/test_url_parallel_benchmark.py diff --git a/studio/tests/test_url_parallel_benchmark.py b/studio/tests/test_url_parallel_benchmark.py new file mode 100644 index 0000000000..a0d160136c --- /dev/null +++ b/studio/tests/test_url_parallel_benchmark.py @@ -0,0 +1,79 @@ +""" +Benchmark: parallel fsspec URL image downloads with ThreadPoolExecutor. +Tests different worker counts to find optimal parallelism. +Dataset: google-research-datasets/conceptual_captions (subset: labeled) +""" +from datasets import load_dataset, Dataset +from PIL import Image as PILImage +from io import BytesIO +from itertools import islice +from concurrent.futures import ThreadPoolExecutor, as_completed +import fsspec +import time +import os + +DATASET = "google-research-datasets/conceptual_captions" +SUBSET = "labeled" +SPLIT = "train" +N_SAMPLES = 500 + +# safe_num_proc formula from studio/backend/utils/hardware/hardware.py +cpu_count = os.cpu_count() +safe_workers = max(1, cpu_count // 3) +print(f"CPU count: {cpu_count}, safe_num_proc: {safe_workers}") + +WORKER_COUNTS = [1, 4, 8, 16, 32, safe_workers] +# Deduplicate and sort +WORKER_COUNTS = sorted(set(WORKER_COUNTS)) + +print(f"Loading {N_SAMPLES} samples from {DATASET} (streaming)...") +ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) +rows = list(islice(ds, N_SAMPLES)) +dataset = Dataset.from_list(rows) +urls = [row["image_url"] for row in dataset] +print(f"Loaded {len(urls)} URLs") +print() + + +def download_single(url): + """Download a single image URL using fsspec. Returns PIL image or raises.""" + with fsspec.open(url, "rb", expand=True) as f: + img = PILImage.open(BytesIO(f.read())).convert("RGB") + return img + + +print(f"{'Workers':>8} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7} | {'Speedup':>8}") +print("-" * 70) + +baseline_throughput = None + +for n_workers in WORKER_COUNTS: + success, fail = 0, 0 + t0 = time.time() + + with ThreadPoolExecutor(max_workers=n_workers) as pool: + futures = {pool.submit(download_single, url): url for url in urls} + for future in as_completed(futures): + try: + img = future.result(timeout=30) + success += 1 + except Exception: + fail += 1 + + elapsed = time.time() - t0 + fail_pct = (fail / N_SAMPLES) * 100 + throughput = success / elapsed if elapsed > 0 else 0 + + if baseline_throughput is None: + baseline_throughput = throughput + speedup = throughput / baseline_throughput if baseline_throughput > 0 else 0 + + label = f"{n_workers}" + if n_workers == safe_workers: + label += "*" # mark the safe_num_proc value + + print(f"{label:>8} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s | {speedup:>7.1f}x") + +print() +print("* = safe_num_proc value") +print("Done.") From 11ebea6a4bc1a90cd1015d8229ce1ebfc12c63d9 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 21:48:40 +0000 Subject: [PATCH 14/33] feat: add index range dataset slicing to studio training page Add Start/End index inputs under Advanced in the dataset card, allowing users to slice a dataset by row range before training. Wired end-to-end: frontend store, API payload, backend Pydantic model, and trainer dataset loading (inclusive on both ends). --- studio/backend/core/training/trainer.py | 16 +- studio/backend/core/training/training.py | 6 +- studio/backend/models/training.py | 2 + studio/backend/routes/training.py | 2 + .../studio/sections/dataset-section.tsx | 141 ++++++++++++------ .../src/features/training/api/mappers.ts | 11 ++ .../training/stores/training-config-store.ts | 12 +- .../src/features/training/types/api.ts | 2 + .../src/features/training/types/config.ts | 4 + 9 files changed, 148 insertions(+), 48 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index c3376c10a2..f5b2f18245 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -353,7 +353,9 @@ class UnslothTrainer: subset: str = None, train_split: str = "train", eval_split: str = None, - eval_steps: float = 0.00) -> Optional[tuple]: + eval_steps: float = 0.00, + dataset_slice_start: int = None, + dataset_slice_end: int = None) -> Optional[tuple]: """ Load and prepare dataset for training. @@ -445,6 +447,18 @@ class UnslothTrainer: if dataset is None: raise ValueError("No dataset provided") + # Apply index range slicing if requested (inclusive on both ends) + if dataset_slice_start is not None or dataset_slice_end is not None: + total_rows = len(dataset) + start = dataset_slice_start if dataset_slice_start is not None else 0 + end = dataset_slice_end if dataset_slice_end is not None else total_rows - 1 + # Clamp to valid range + start = max(0, min(start, total_rows - 1)) + end = max(start, min(end, total_rows - 1)) + dataset = dataset.select(range(start, end + 1)) + print(f"Sliced dataset to rows [{start}, {end}]: {len(dataset)} of {total_rows} rows\n") + self._update_progress(status_message=f"Sliced dataset to {len(dataset)} rows (indices {start}-{end})") + # Check if stopped before applying template if self.should_stop: print("Stopped before applying chat template\n") diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index 9123d36b39..153f4335e3 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -116,7 +116,9 @@ class TrainingBackend: train_split: str = "train", eval_split: str = None, eval_steps: float = 0.00, - is_dataset_multimodal: bool = False) -> bool: + is_dataset_multimodal: bool = False, + dataset_slice_start: int = None, + dataset_slice_end: int = None) -> bool: """ Start training. @@ -224,6 +226,8 @@ class TrainingBackend: train_split=train_split, eval_split=eval_split, eval_steps=eval_steps, + dataset_slice_start=dataset_slice_start, + dataset_slice_end=dataset_slice_end, ) # Unpack: load_and_format_dataset returns (dataset, eval_dataset) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 54de974100..b6b30989bd 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -22,6 +22,8 @@ class TrainingStartRequest(BaseModel): train_split: Optional[str] = Field("train", description="Training split name") eval_split: Optional[str] = Field(None, description="Eval split name. None = auto-detect") eval_steps: float = Field(0.00, description="Fraction of total steps between evals (0-1)") + dataset_slice_start: Optional[int] = Field(None, description="Inclusive start row index for dataset slicing") + dataset_slice_end: Optional[int] = Field(None, description="Inclusive end row index for dataset slicing") @model_validator(mode="before") @classmethod diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index f8de2f639f..497daaedd3 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -149,6 +149,8 @@ async def start_training( "train_split": request.train_split, "eval_split": request.eval_split, "eval_steps": request.eval_steps, + "dataset_slice_start": request.dataset_slice_start, + "dataset_slice_end": request.dataset_slice_end, "custom_format_mapping": request.custom_format_mapping, "num_epochs": request.num_epochs, "learning_rate": request.learning_rate, diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 57e50bced5..00ee520120 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,6 +13,7 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -75,6 +76,10 @@ export function DatasetSection() { setDatasetEvalSplit, hfToken, modelType, + datasetSliceStart, + setDatasetSliceStart, + datasetSliceEnd, + setDatasetSliceEnd, } = useTrainingConfigStore( useShallow((s) => ({ dataset: s.dataset, @@ -89,6 +94,10 @@ export function DatasetSection() { setDatasetEvalSplit: s.setDatasetEvalSplit, hfToken: s.hfToken, modelType: s.modelType, + datasetSliceStart: s.datasetSliceStart, + setDatasetSliceStart: s.setDatasetSliceStart, + datasetSliceEnd: s.datasetSliceEnd, + setDatasetSliceEnd: s.setDatasetSliceEnd, })), ); @@ -293,51 +302,93 @@ export function DatasetSection() { Advanced -
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - +
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + + +
+
+ + Index Range + + + + + + Slice the dataset by row index. Both start and end are + inclusive. Leave empty to use all rows. + + + +
+ + setDatasetSliceStart(e.target.value || null) + } + /> + + setDatasetSliceEnd(e.target.value || null) + } + /> +
+
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts index 1adfbd8b6d..cd0d1f14e1 100644 --- a/studio/frontend/src/features/training/api/mappers.ts +++ b/studio/frontend/src/features/training/api/mappers.ts @@ -4,6 +4,15 @@ import type { TrainingStartRequest } from "../types/api"; const BACKEND_LORA_TYPE = "LoRA/QLoRA"; const BACKEND_FULL_TYPE = "Full Finetuning"; +function parseSliceValue(value: string | null): number | null { + if (value == null) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const num = Number(trimmed); + if (!Number.isFinite(num) || !Number.isInteger(num)) return null; + return num; +} + export function toBackendTrainingType(trainingMethod: string): string { return trainingMethod === "full" ? BACKEND_FULL_TYPE : BACKEND_LORA_TYPE; } @@ -27,6 +36,8 @@ export function buildTrainingStartPayload( subset: hfDataset ? config.datasetSubset : null, train_split: hfDataset ? config.datasetSplit : null, eval_split: hfDataset ? config.datasetEvalSplit : null, + dataset_slice_start: parseSliceValue(config.datasetSliceStart), + dataset_slice_end: parseSliceValue(config.datasetSliceEnd), local_datasets: [], format_type: config.datasetFormat, custom_format_mapping: customFormatMapping, diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index b2d1858716..93c742ba98 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -28,6 +28,8 @@ const initialState: TrainingConfigState = { datasetSplit: null, datasetEvalSplit: null, datasetManualMapping: emptyManualMapping(), + datasetSliceStart: null, + datasetSliceEnd: null, uploadedFile: null, isCheckingVision: false, isVisionModel: false, @@ -255,6 +257,8 @@ export const useTrainingConfigStore = create()( datasetSplit: null, datasetEvalSplit: null, datasetManualMapping: emptyManualMapping(), + datasetSliceStart: null, + datasetSliceEnd: null, isDatasetMultimodal: null, isCheckingDataset: false, }); @@ -311,6 +315,8 @@ export const useTrainingConfigStore = create()( }, setDatasetManualMapping: (datasetManualMapping) => set({ datasetManualMapping }), + setDatasetSliceStart: (datasetSliceStart) => set({ datasetSliceStart }), + setDatasetSliceEnd: (datasetSliceEnd) => set({ datasetSliceEnd }), setUploadedFile: (uploadedFile) => set({ uploadedFile }), setEpochs: (epochs) => set({ epochs }), setContextLength: (contextLength) => set({ contextLength }), @@ -368,7 +374,7 @@ export const useTrainingConfigStore = create()( }, { name: "unsloth_training_config_v1", - version: 6, + version: 7, migrate: (persisted, version) => { const s = persisted as Record; if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) { @@ -387,6 +393,10 @@ export const useTrainingConfigStore = create()( if (version < 6 && s.datasetEvalSplit == null) { s.datasetEvalSplit = null; } + if (version < 7) { + s.datasetSliceStart ??= null; + s.datasetSliceEnd ??= null; + } return s as unknown as TrainingConfigStore; }, partialize: partializePersistedState, diff --git a/studio/frontend/src/features/training/types/api.ts b/studio/frontend/src/features/training/types/api.ts index 22f02e4331..e2fcc04ad4 100644 --- a/studio/frontend/src/features/training/types/api.ts +++ b/studio/frontend/src/features/training/types/api.ts @@ -8,6 +8,8 @@ export interface TrainingStartRequest { subset: string | null; train_split: string | null; eval_split: string | null; + dataset_slice_start: number | null; + dataset_slice_end: number | null; local_datasets: string[]; format_type: string; custom_format_mapping?: Record | null; diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts index 6c2feec172..0e48d18861 100644 --- a/studio/frontend/src/features/training/types/config.ts +++ b/studio/frontend/src/features/training/types/config.ts @@ -26,6 +26,8 @@ export interface TrainingConfigState { datasetSplit: string | null; datasetEvalSplit: string | null; datasetManualMapping: DatasetManualMapping; + datasetSliceStart: string | null; + datasetSliceEnd: string | null; uploadedFile: string | null; epochs: number; contextLength: number; @@ -84,6 +86,8 @@ export interface TrainingConfigActions { setDatasetSplit: (split: string | null) => void; setDatasetEvalSplit: (split: string | null) => void; setDatasetManualMapping: (mapping: DatasetManualMapping) => void; + setDatasetSliceStart: (value: string | null) => void; + setDatasetSliceEnd: (value: string | null) => void; setUploadedFile: (file: string | null) => void; setEpochs: (epochs: number) => void; setContextLength: (length: number) => void; From 07bbe7bae57ea2a375dd59a62b54a2b9c7c164ee Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 22:35:17 +0000 Subject: [PATCH 15/33] refactor: move index range fields next to eval split in 3-col grid Place Slice Start and Slice End inputs alongside the Eval Split selector in a single row (grid-cols-3) so the dataset card stays compact. Remove the duplicate controls from the Advanced section. --- .../studio/sections/dataset-section.tsx | 137 +++++++----------- .../hf-dataset-subset-split-selectors.tsx | 102 +++++++++++-- 2 files changed, 141 insertions(+), 98 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 00ee520120..338a264579 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,7 +13,6 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; -import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -291,6 +290,10 @@ export function DatasetSection() { setDatasetSplit={setDatasetSplit} datasetEvalSplit={datasetEvalSplit} setDatasetEvalSplit={setDatasetEvalSplit} + datasetSliceStart={datasetSliceStart} + setDatasetSliceStart={setDatasetSliceStart} + datasetSliceEnd={datasetSliceEnd} + setDatasetSliceEnd={setDatasetSliceEnd} /> @@ -302,93 +305,51 @@ export function DatasetSection() { Advanced -
-
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - -
-
- - Index Range - - - - - - Slice the dataset by row index. Both start and end are - inclusive. Leave empty to use all rows. - - - -
- - setDatasetSliceStart(e.target.value || null) - } - /> - - setDatasetSliceEnd(e.target.value || null) - } - /> -
-
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + +
diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index d21fd55ff4..5a21ec6d83 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -5,6 +5,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, @@ -31,6 +32,10 @@ type Props = { setDatasetSplit: (v: string | null) => void; datasetEvalSplit: string | null; setDatasetEvalSplit: (v: string | null) => void; + datasetSliceStart?: string | null; + setDatasetSliceStart?: (v: string | null) => void; + datasetSliceEnd?: string | null; + setDatasetSliceEnd?: (v: string | null) => void; }; export function HfDatasetSubsetSplitSelectors({ @@ -44,6 +49,10 @@ export function HfDatasetSubsetSplitSelectors({ setDatasetSplit, datasetEvalSplit, setDatasetEvalSplit, + datasetSliceStart, + setDatasetSliceStart, + datasetSliceEnd, + setDatasetSliceEnd, }: Props) { const { subsets: hfSubsets, @@ -155,16 +164,89 @@ export function HfDatasetSubsetSplitSelectors({ /> )} - + {variant === "studio" && setDatasetSliceStart && setDatasetSliceEnd ? ( +
+ +
+ + Slice Start + + + + + + Inclusive start row index. Leave empty to start from the beginning. + + + + + setDatasetSliceStart(e.target.value || null) + } + /> +
+
+ + Slice End + + + + + + Inclusive end row index. Leave empty to use all remaining rows. + + + + + setDatasetSliceEnd(e.target.value || null) + } + /> +
+
+ ) : ( + + )} )} From 7f8c0867d5277d4a76c81db83a822c17349bf986 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 23:07:36 +0000 Subject: [PATCH 16/33] refactor: move train split slice controls back to Advanced section Place Train Split Start / End inputs inside the Advanced collapsible with descriptive tooltips clarifying they slice the training split. Revert the selectors component to its original eval-split-only layout. --- .../studio/sections/dataset-section.tsx | 164 ++++++++++++------ .../hf-dataset-subset-split-selectors.tsx | 102 ++--------- 2 files changed, 125 insertions(+), 141 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 338a264579..bb65f704ac 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,6 +13,7 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -290,10 +291,6 @@ export function DatasetSection() { setDatasetSplit={setDatasetSplit} datasetEvalSplit={datasetEvalSplit} setDatasetEvalSplit={setDatasetEvalSplit} - datasetSliceStart={datasetSliceStart} - setDatasetSliceStart={setDatasetSliceStart} - datasetSliceEnd={datasetSliceEnd} - setDatasetSliceEnd={setDatasetSliceEnd} /> @@ -305,51 +302,120 @@ export function DatasetSection() { Advanced -
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - +
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + + +
+
+
+ + Train Split Start + + + + + + Only train on a subset of your training split by + specifying a start row index (inclusive, 0-based). + Useful for resuming from a checkpoint or debugging + with a smaller slice. Leave empty to start from the + first row. + + + + + setDatasetSliceStart(e.target.value || null) + } + /> +
+
+ + Train Split End + + + + + + Last row index to include from the training split + (inclusive, 0-based). For example, set Start to 0 and + End to 99 to train on the first 100 rows. Leave empty + to use all remaining rows. + + + + + setDatasetSliceEnd(e.target.value || null) + } + /> +
+
diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index 5a21ec6d83..d21fd55ff4 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -5,7 +5,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, @@ -32,10 +31,6 @@ type Props = { setDatasetSplit: (v: string | null) => void; datasetEvalSplit: string | null; setDatasetEvalSplit: (v: string | null) => void; - datasetSliceStart?: string | null; - setDatasetSliceStart?: (v: string | null) => void; - datasetSliceEnd?: string | null; - setDatasetSliceEnd?: (v: string | null) => void; }; export function HfDatasetSubsetSplitSelectors({ @@ -49,10 +44,6 @@ export function HfDatasetSubsetSplitSelectors({ setDatasetSplit, datasetEvalSplit, setDatasetEvalSplit, - datasetSliceStart, - setDatasetSliceStart, - datasetSliceEnd, - setDatasetSliceEnd, }: Props) { const { subsets: hfSubsets, @@ -164,89 +155,16 @@ export function HfDatasetSubsetSplitSelectors({ /> )} - {variant === "studio" && setDatasetSliceStart && setDatasetSliceEnd ? ( -
- -
- - Slice Start - - - - - - Inclusive start row index. Leave empty to start from the beginning. - - - - - setDatasetSliceStart(e.target.value || null) - } - /> -
-
- - Slice End - - - - - - Inclusive end row index. Leave empty to use all remaining rows. - - - - - setDatasetSliceEnd(e.target.value || null) - } - /> -
-
- ) : ( - - )} + )} From 42ee6fe443babf4544689d0a6c0b99bf86fca94d Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 23:15:49 +0000 Subject: [PATCH 17/33] fix: remove unnecessary tooltip copy from train split start --- .../frontend/src/features/studio/sections/dataset-section.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index bb65f704ac..28254adcc7 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -368,9 +368,7 @@ export function DatasetSection() { Only train on a subset of your training split by specifying a start row index (inclusive, 0-based). - Useful for resuming from a checkpoint or debugging - with a smaller slice. Leave empty to start from the - first row. + Leave empty to start from the first row. From 91783c0fb29cba95354c7d5b43a24d3e8ef6e394 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:21:07 +0400 Subject: [PATCH 18/33] Revert "Add index range dataset slicing to Studio training page" --- studio/backend/core/training/trainer.py | 24 +-- studio/backend/core/training/training.py | 6 +- studio/backend/models/training.py | 2 - studio/backend/routes/training.py | 2 - .../utils/datasets/format_conversion.py | 64 +------ .../studio/sections/dataset-section.tsx | 166 +++++------------- .../src/features/training/api/mappers.ts | 11 -- .../training/stores/training-config-store.ts | 12 +- .../src/features/training/types/api.ts | 2 - .../src/features/training/types/config.ts | 4 - studio/tests/test_url_download_benchmark.py | 54 ------ studio/tests/test_url_image_loading.py | 132 -------------- studio/tests/test_url_parallel_benchmark.py | 79 --------- 13 files changed, 55 insertions(+), 503 deletions(-) delete mode 100644 studio/tests/test_url_download_benchmark.py delete mode 100644 studio/tests/test_url_image_loading.py delete mode 100644 studio/tests/test_url_parallel_benchmark.py diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index f5b2f18245..e4e7a474be 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -353,9 +353,7 @@ class UnslothTrainer: subset: str = None, train_split: str = "train", eval_split: str = None, - eval_steps: float = 0.00, - dataset_slice_start: int = None, - dataset_slice_end: int = None) -> Optional[tuple]: + eval_steps: float = 0.00) -> Optional[tuple]: """ Load and prepare dataset for training. @@ -447,18 +445,6 @@ class UnslothTrainer: if dataset is None: raise ValueError("No dataset provided") - # Apply index range slicing if requested (inclusive on both ends) - if dataset_slice_start is not None or dataset_slice_end is not None: - total_rows = len(dataset) - start = dataset_slice_start if dataset_slice_start is not None else 0 - end = dataset_slice_end if dataset_slice_end is not None else total_rows - 1 - # Clamp to valid range - start = max(0, min(start, total_rows - 1)) - end = max(start, min(end, total_rows - 1)) - dataset = dataset.select(range(start, end + 1)) - print(f"Sliced dataset to rows [{start}, {end}]: {len(dataset)} of {total_rows} rows\n") - self._update_progress(status_message=f"Sliced dataset to {len(dataset)} rows (indices {start}-{end})") - # Check if stopped before applying template if self.should_stop: print("Stopped before applying chat template\n") @@ -482,14 +468,6 @@ class UnslothTrainer: print("Stopped during dataset formatting\n") return None - # Abort if dataset formatting/conversion failed - if not dataset_info.get("success", True): - errors = dataset_info.get("errors", []) - error_msg = "; ".join(errors) if errors else "Dataset formatting failed" - logger.error(f"Dataset conversion failed: {error_msg}") - self._update_progress(error=error_msg) - return None - self._update_progress(status_message=f"Dataset formatted and ready for training") print(f"Dataset formatted successfully\n") diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index 153f4335e3..9123d36b39 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -116,9 +116,7 @@ class TrainingBackend: train_split: str = "train", eval_split: str = None, eval_steps: float = 0.00, - is_dataset_multimodal: bool = False, - dataset_slice_start: int = None, - dataset_slice_end: int = None) -> bool: + is_dataset_multimodal: bool = False) -> bool: """ Start training. @@ -226,8 +224,6 @@ class TrainingBackend: train_split=train_split, eval_split=eval_split, eval_steps=eval_steps, - dataset_slice_start=dataset_slice_start, - dataset_slice_end=dataset_slice_end, ) # Unpack: load_and_format_dataset returns (dataset, eval_dataset) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index b6b30989bd..54de974100 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -22,8 +22,6 @@ class TrainingStartRequest(BaseModel): train_split: Optional[str] = Field("train", description="Training split name") eval_split: Optional[str] = Field(None, description="Eval split name. None = auto-detect") eval_steps: float = Field(0.00, description="Fraction of total steps between evals (0-1)") - dataset_slice_start: Optional[int] = Field(None, description="Inclusive start row index for dataset slicing") - dataset_slice_end: Optional[int] = Field(None, description="Inclusive end row index for dataset slicing") @model_validator(mode="before") @classmethod diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index 497daaedd3..f8de2f639f 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -149,8 +149,6 @@ async def start_training( "train_split": request.train_split, "eval_split": request.eval_split, "eval_steps": request.eval_steps, - "dataset_slice_start": request.dataset_slice_start, - "dataset_slice_end": request.dataset_slice_end, "custom_format_mapping": request.custom_format_mapping, "num_epochs": request.num_epochs, "learning_rate": request.learning_rate, diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index e784e8e645..6436e7a82a 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -281,17 +281,12 @@ def convert_to_vlm_format( def _convert_single_sample(sample): """Convert a single sample to VLM format.""" - # Get image (might be PIL Image, local path, or URL) + # Get image (might be PIL Image or path) image_data = sample[image_column] + # Handle image paths if isinstance(image_data, str): - if image_data.startswith(("http://", "https://")): - import fsspec - from io import BytesIO - with fsspec.open(image_data, "rb", expand=True) as f: - image_data = Image.open(BytesIO(f.read())).convert("RGB") - else: - image_data = Image.open(image_data).convert("RGB") + image_data = Image.open(image_data).convert("RGB") # Get text text_data = sample[text_column] @@ -322,56 +317,11 @@ def convert_to_vlm_format( # Return dict with messages return {"messages": messages} - # Convert samples, skipping any with broken/unreachable images. - # For URL-based datasets, check the first PROBE_SIZE samples early to - # fail fast if too many images are broken, before downloading millions. - PROBE_SIZE = 5000 - MAX_FAIL_RATE = 0.3 + # Use list comprehension and return the LIST directly + print(f"🔄 Converting {len(dataset)} samples to VLM format...") + converted_list = [_convert_single_sample(sample) for sample in dataset] - total = len(dataset) - has_urls = isinstance(next(iter(dataset))[image_column], str) - probe_needed = has_urls and total > PROBE_SIZE - - from tqdm import tqdm - - print(f"🔄 Converting {total} samples to VLM format...") - converted_list = [] - failed_count = 0 - - pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample") - for i, sample in enumerate(pbar): - try: - converted_list.append(_convert_single_sample(sample)) - except Exception as e: - failed_count += 1 - - pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False) - - # Early exit check after probing the first batch - if probe_needed and (i + 1) == PROBE_SIZE: - fail_rate = failed_count / PROBE_SIZE - if fail_rate >= MAX_FAIL_RATE: - pbar.close() - raise ValueError( - f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " - f"({failed_count}/{PROBE_SIZE}). " - "This dataset has too many broken or unreachable image URLs. " - "Consider using a dataset with embedded images instead." - ) - print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...") - pbar.close() - - if failed_count > 0: - fail_rate = failed_count / total - print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images") - - if len(converted_list) == 0: - raise ValueError( - f"All {total} samples failed during VLM conversion — no usable images found. " - "This dataset may contain only image URLs that are no longer accessible." - ) - - print(f"✅ Converted {len(converted_list)}/{total} samples") + print(f"✅ Converted {len(converted_list)} samples") # Return list, NOT Dataset return converted_list diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 28254adcc7..57e50bced5 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,7 +13,6 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; -import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -76,10 +75,6 @@ export function DatasetSection() { setDatasetEvalSplit, hfToken, modelType, - datasetSliceStart, - setDatasetSliceStart, - datasetSliceEnd, - setDatasetSliceEnd, } = useTrainingConfigStore( useShallow((s) => ({ dataset: s.dataset, @@ -94,10 +89,6 @@ export function DatasetSection() { setDatasetEvalSplit: s.setDatasetEvalSplit, hfToken: s.hfToken, modelType: s.modelType, - datasetSliceStart: s.datasetSliceStart, - setDatasetSliceStart: s.setDatasetSliceStart, - datasetSliceEnd: s.datasetSliceEnd, - setDatasetSliceEnd: s.setDatasetSliceEnd, })), ); @@ -302,118 +293,51 @@ export function DatasetSection() { Advanced -
-
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - -
-
-
- - Train Split Start - - - - - - Only train on a subset of your training split by - specifying a start row index (inclusive, 0-based). - Leave empty to start from the first row. - - - - - setDatasetSliceStart(e.target.value || null) - } - /> -
-
- - Train Split End - - - - - - Last row index to include from the training split - (inclusive, 0-based). For example, set Start to 0 and - End to 99 to train on the first 100 rows. Leave empty - to use all remaining rows. - - - - - setDatasetSliceEnd(e.target.value || null) - } - /> -
-
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + +
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts index cd0d1f14e1..1adfbd8b6d 100644 --- a/studio/frontend/src/features/training/api/mappers.ts +++ b/studio/frontend/src/features/training/api/mappers.ts @@ -4,15 +4,6 @@ import type { TrainingStartRequest } from "../types/api"; const BACKEND_LORA_TYPE = "LoRA/QLoRA"; const BACKEND_FULL_TYPE = "Full Finetuning"; -function parseSliceValue(value: string | null): number | null { - if (value == null) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const num = Number(trimmed); - if (!Number.isFinite(num) || !Number.isInteger(num)) return null; - return num; -} - export function toBackendTrainingType(trainingMethod: string): string { return trainingMethod === "full" ? BACKEND_FULL_TYPE : BACKEND_LORA_TYPE; } @@ -36,8 +27,6 @@ export function buildTrainingStartPayload( subset: hfDataset ? config.datasetSubset : null, train_split: hfDataset ? config.datasetSplit : null, eval_split: hfDataset ? config.datasetEvalSplit : null, - dataset_slice_start: parseSliceValue(config.datasetSliceStart), - dataset_slice_end: parseSliceValue(config.datasetSliceEnd), local_datasets: [], format_type: config.datasetFormat, custom_format_mapping: customFormatMapping, diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index 93c742ba98..b2d1858716 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -28,8 +28,6 @@ const initialState: TrainingConfigState = { datasetSplit: null, datasetEvalSplit: null, datasetManualMapping: emptyManualMapping(), - datasetSliceStart: null, - datasetSliceEnd: null, uploadedFile: null, isCheckingVision: false, isVisionModel: false, @@ -257,8 +255,6 @@ export const useTrainingConfigStore = create()( datasetSplit: null, datasetEvalSplit: null, datasetManualMapping: emptyManualMapping(), - datasetSliceStart: null, - datasetSliceEnd: null, isDatasetMultimodal: null, isCheckingDataset: false, }); @@ -315,8 +311,6 @@ export const useTrainingConfigStore = create()( }, setDatasetManualMapping: (datasetManualMapping) => set({ datasetManualMapping }), - setDatasetSliceStart: (datasetSliceStart) => set({ datasetSliceStart }), - setDatasetSliceEnd: (datasetSliceEnd) => set({ datasetSliceEnd }), setUploadedFile: (uploadedFile) => set({ uploadedFile }), setEpochs: (epochs) => set({ epochs }), setContextLength: (contextLength) => set({ contextLength }), @@ -374,7 +368,7 @@ export const useTrainingConfigStore = create()( }, { name: "unsloth_training_config_v1", - version: 7, + version: 6, migrate: (persisted, version) => { const s = persisted as Record; if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) { @@ -393,10 +387,6 @@ export const useTrainingConfigStore = create()( if (version < 6 && s.datasetEvalSplit == null) { s.datasetEvalSplit = null; } - if (version < 7) { - s.datasetSliceStart ??= null; - s.datasetSliceEnd ??= null; - } return s as unknown as TrainingConfigStore; }, partialize: partializePersistedState, diff --git a/studio/frontend/src/features/training/types/api.ts b/studio/frontend/src/features/training/types/api.ts index e2fcc04ad4..22f02e4331 100644 --- a/studio/frontend/src/features/training/types/api.ts +++ b/studio/frontend/src/features/training/types/api.ts @@ -8,8 +8,6 @@ export interface TrainingStartRequest { subset: string | null; train_split: string | null; eval_split: string | null; - dataset_slice_start: number | null; - dataset_slice_end: number | null; local_datasets: string[]; format_type: string; custom_format_mapping?: Record | null; diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts index 0e48d18861..6c2feec172 100644 --- a/studio/frontend/src/features/training/types/config.ts +++ b/studio/frontend/src/features/training/types/config.ts @@ -26,8 +26,6 @@ export interface TrainingConfigState { datasetSplit: string | null; datasetEvalSplit: string | null; datasetManualMapping: DatasetManualMapping; - datasetSliceStart: string | null; - datasetSliceEnd: string | null; uploadedFile: string | null; epochs: number; contextLength: number; @@ -86,8 +84,6 @@ export interface TrainingConfigActions { setDatasetSplit: (split: string | null) => void; setDatasetEvalSplit: (split: string | null) => void; setDatasetManualMapping: (mapping: DatasetManualMapping) => void; - setDatasetSliceStart: (value: string | null) => void; - setDatasetSliceEnd: (value: string | null) => void; setUploadedFile: (file: string | null) => void; setEpochs: (epochs: number) => void; setContextLength: (length: number) => void; diff --git a/studio/tests/test_url_download_benchmark.py b/studio/tests/test_url_download_benchmark.py deleted file mode 100644 index e29a8e05b6..0000000000 --- a/studio/tests/test_url_download_benchmark.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Benchmark: fsspec URL image download throughput at different dataset sizes. -Dataset: google-research-datasets/conceptual_captions (subset: labeled) - -Tests sizes: 100, 200, 300, 500, 1000, 1500, 2000 -Reports: time, success/fail rate, throughput (images/sec) -""" -from datasets import load_dataset, Dataset -from PIL import Image as PILImage -from io import BytesIO -from itertools import islice -import fsspec -import time - -DATASET = "google-research-datasets/conceptual_captions" -SUBSET = "labeled" -SPLIT = "train" -SIZES = [100, 200, 300, 500, 1000, 1500, 2000] - -# Load the max we need in one go -max_size = max(SIZES) -print(f"Loading {max_size} samples from {DATASET} (streaming)...") -ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) -rows = list(islice(ds, max_size)) -full_dataset = Dataset.from_list(rows) -print(f"Loaded {len(full_dataset)} samples") -print(f"Columns: {full_dataset.column_names}") -print() - -print(f"{'Size':>6} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7}") -print("-" * 55) - -for size in SIZES: - dataset = full_dataset.select(range(size)) - success, fail = 0, 0 - t0 = time.time() - - for sample in dataset: - url = sample["image_url"] - try: - with fsspec.open(url, "rb", expand=True) as f: - img = PILImage.open(BytesIO(f.read())).convert("RGB") - success += 1 - except Exception: - fail += 1 - - elapsed = time.time() - t0 - fail_pct = (fail / size) * 100 - throughput = success / elapsed if elapsed > 0 else 0 - - print(f"{size:>6} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s") - -print() -print("Done.") diff --git a/studio/tests/test_url_image_loading.py b/studio/tests/test_url_image_loading.py deleted file mode 100644 index 4899bab8e3..0000000000 --- a/studio/tests/test_url_image_loading.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Reproduce: VLM URL image loading with HF datasets. -Tests cast_column(Image()) vs manual download approaches. -Dataset: google-research-datasets/conceptual_captions (subset: labeled) -""" -from datasets import load_dataset, Image as datasets_Image, Dataset -from PIL import Image as PILImage -from io import BytesIO -from itertools import islice -import time - -DATASET = "google-research-datasets/conceptual_captions" -SUBSET = "labeled" -SPLIT = "train" -N_SAMPLES = 20 # small slice for testing - -print("=" * 60) -print("Loading dataset (streaming, first N samples)...") -print("=" * 60) -ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) -rows = list(islice(ds, N_SAMPLES)) -dataset = Dataset.from_list(rows) - -print(f"Loaded {len(dataset)} samples") -print(f"Columns: {dataset.column_names}") -print(f"First image_url: {dataset[0]['image_url'][:100]}...") -print() - -# ─── Test 1: cast_column(Image()) — what we tried ─── -print("=" * 60) -print("TEST 1: cast_column(Image()) approach") -print("=" * 60) -try: - ds_cast = dataset.cast_column("image_url", datasets_Image()) - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(ds_cast): - try: - img = sample["image_url"] - if img is not None: - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - else: - print(f" [{i}] None returned") - fail += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED during iteration: {type(e).__name__}: {str(e)[:120]}") -print() - -# ─── Test 2: Manual download with requests.Session ─── -print("=" * 60) -print("TEST 2: requests.Session() approach") -print("=" * 60) -try: - import requests - session = requests.Session() - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(dataset): - url = sample["image_url"] - try: - resp = session.get(url, timeout=10) - resp.raise_for_status() - img = PILImage.open(BytesIO(resp.content)).convert("RGB") - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") -print() - -# ─── Test 3: urllib (stdlib) ─── -print("=" * 60) -print("TEST 3: urllib approach (stdlib)") -print("=" * 60) -try: - from urllib.request import urlopen, Request - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(dataset): - url = sample["image_url"] - try: - req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) - with urlopen(req, timeout=10) as resp: - img = PILImage.open(BytesIO(resp.read())).convert("RGB") - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") -print() - -# ─── Test 4: fsspec directly with expand=True ─── -print("=" * 60) -print("TEST 4: fsspec.open() with expand=True") -print("=" * 60) -try: - import fsspec - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(dataset): - url = sample["image_url"] - try: - with fsspec.open(url, "rb", expand=True) as f: - img = PILImage.open(BytesIO(f.read())).convert("RGB") - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") - -print() -print("=" * 60) -print("DONE — compare success rates and timing above") -print("=" * 60) diff --git a/studio/tests/test_url_parallel_benchmark.py b/studio/tests/test_url_parallel_benchmark.py deleted file mode 100644 index a0d160136c..0000000000 --- a/studio/tests/test_url_parallel_benchmark.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Benchmark: parallel fsspec URL image downloads with ThreadPoolExecutor. -Tests different worker counts to find optimal parallelism. -Dataset: google-research-datasets/conceptual_captions (subset: labeled) -""" -from datasets import load_dataset, Dataset -from PIL import Image as PILImage -from io import BytesIO -from itertools import islice -from concurrent.futures import ThreadPoolExecutor, as_completed -import fsspec -import time -import os - -DATASET = "google-research-datasets/conceptual_captions" -SUBSET = "labeled" -SPLIT = "train" -N_SAMPLES = 500 - -# safe_num_proc formula from studio/backend/utils/hardware/hardware.py -cpu_count = os.cpu_count() -safe_workers = max(1, cpu_count // 3) -print(f"CPU count: {cpu_count}, safe_num_proc: {safe_workers}") - -WORKER_COUNTS = [1, 4, 8, 16, 32, safe_workers] -# Deduplicate and sort -WORKER_COUNTS = sorted(set(WORKER_COUNTS)) - -print(f"Loading {N_SAMPLES} samples from {DATASET} (streaming)...") -ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) -rows = list(islice(ds, N_SAMPLES)) -dataset = Dataset.from_list(rows) -urls = [row["image_url"] for row in dataset] -print(f"Loaded {len(urls)} URLs") -print() - - -def download_single(url): - """Download a single image URL using fsspec. Returns PIL image or raises.""" - with fsspec.open(url, "rb", expand=True) as f: - img = PILImage.open(BytesIO(f.read())).convert("RGB") - return img - - -print(f"{'Workers':>8} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7} | {'Speedup':>8}") -print("-" * 70) - -baseline_throughput = None - -for n_workers in WORKER_COUNTS: - success, fail = 0, 0 - t0 = time.time() - - with ThreadPoolExecutor(max_workers=n_workers) as pool: - futures = {pool.submit(download_single, url): url for url in urls} - for future in as_completed(futures): - try: - img = future.result(timeout=30) - success += 1 - except Exception: - fail += 1 - - elapsed = time.time() - t0 - fail_pct = (fail / N_SAMPLES) * 100 - throughput = success / elapsed if elapsed > 0 else 0 - - if baseline_throughput is None: - baseline_throughput = throughput - speedup = throughput / baseline_throughput if baseline_throughput > 0 else 0 - - label = f"{n_workers}" - if n_workers == safe_workers: - label += "*" # mark the safe_num_proc value - - print(f"{label:>8} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s | {speedup:>7.1f}x") - -print() -print("* = safe_num_proc value") -print("Done.") From a80188848d08c50eec1975b296a6e89aebbc1a34 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 21:48:40 +0000 Subject: [PATCH 19/33] feat: add index range dataset slicing to studio training page Add Start/End index inputs under Advanced in the dataset card, allowing users to slice a dataset by row range before training. Wired end-to-end: frontend store, API payload, backend Pydantic model, and trainer dataset loading (inclusive on both ends). --- studio/backend/core/training/trainer.py | 16 +- studio/backend/core/training/training.py | 6 +- studio/backend/models/training.py | 2 + studio/backend/routes/training.py | 2 + .../studio/sections/dataset-section.tsx | 141 ++++++++++++------ .../src/features/training/api/mappers.ts | 11 ++ .../training/stores/training-config-store.ts | 12 +- .../src/features/training/types/api.ts | 2 + .../src/features/training/types/config.ts | 4 + 9 files changed, 148 insertions(+), 48 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index e4e7a474be..5ce273f43c 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -353,7 +353,9 @@ class UnslothTrainer: subset: str = None, train_split: str = "train", eval_split: str = None, - eval_steps: float = 0.00) -> Optional[tuple]: + eval_steps: float = 0.00, + dataset_slice_start: int = None, + dataset_slice_end: int = None) -> Optional[tuple]: """ Load and prepare dataset for training. @@ -445,6 +447,18 @@ class UnslothTrainer: if dataset is None: raise ValueError("No dataset provided") + # Apply index range slicing if requested (inclusive on both ends) + if dataset_slice_start is not None or dataset_slice_end is not None: + total_rows = len(dataset) + start = dataset_slice_start if dataset_slice_start is not None else 0 + end = dataset_slice_end if dataset_slice_end is not None else total_rows - 1 + # Clamp to valid range + start = max(0, min(start, total_rows - 1)) + end = max(start, min(end, total_rows - 1)) + dataset = dataset.select(range(start, end + 1)) + print(f"Sliced dataset to rows [{start}, {end}]: {len(dataset)} of {total_rows} rows\n") + self._update_progress(status_message=f"Sliced dataset to {len(dataset)} rows (indices {start}-{end})") + # Check if stopped before applying template if self.should_stop: print("Stopped before applying chat template\n") diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index 9123d36b39..153f4335e3 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -116,7 +116,9 @@ class TrainingBackend: train_split: str = "train", eval_split: str = None, eval_steps: float = 0.00, - is_dataset_multimodal: bool = False) -> bool: + is_dataset_multimodal: bool = False, + dataset_slice_start: int = None, + dataset_slice_end: int = None) -> bool: """ Start training. @@ -224,6 +226,8 @@ class TrainingBackend: train_split=train_split, eval_split=eval_split, eval_steps=eval_steps, + dataset_slice_start=dataset_slice_start, + dataset_slice_end=dataset_slice_end, ) # Unpack: load_and_format_dataset returns (dataset, eval_dataset) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 54de974100..b6b30989bd 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -22,6 +22,8 @@ class TrainingStartRequest(BaseModel): train_split: Optional[str] = Field("train", description="Training split name") eval_split: Optional[str] = Field(None, description="Eval split name. None = auto-detect") eval_steps: float = Field(0.00, description="Fraction of total steps between evals (0-1)") + dataset_slice_start: Optional[int] = Field(None, description="Inclusive start row index for dataset slicing") + dataset_slice_end: Optional[int] = Field(None, description="Inclusive end row index for dataset slicing") @model_validator(mode="before") @classmethod diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index f8de2f639f..497daaedd3 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -149,6 +149,8 @@ async def start_training( "train_split": request.train_split, "eval_split": request.eval_split, "eval_steps": request.eval_steps, + "dataset_slice_start": request.dataset_slice_start, + "dataset_slice_end": request.dataset_slice_end, "custom_format_mapping": request.custom_format_mapping, "num_epochs": request.num_epochs, "learning_rate": request.learning_rate, diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 57e50bced5..00ee520120 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,6 +13,7 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -75,6 +76,10 @@ export function DatasetSection() { setDatasetEvalSplit, hfToken, modelType, + datasetSliceStart, + setDatasetSliceStart, + datasetSliceEnd, + setDatasetSliceEnd, } = useTrainingConfigStore( useShallow((s) => ({ dataset: s.dataset, @@ -89,6 +94,10 @@ export function DatasetSection() { setDatasetEvalSplit: s.setDatasetEvalSplit, hfToken: s.hfToken, modelType: s.modelType, + datasetSliceStart: s.datasetSliceStart, + setDatasetSliceStart: s.setDatasetSliceStart, + datasetSliceEnd: s.datasetSliceEnd, + setDatasetSliceEnd: s.setDatasetSliceEnd, })), ); @@ -293,51 +302,93 @@ export function DatasetSection() { Advanced -
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - +
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + + +
+
+ + Index Range + + + + + + Slice the dataset by row index. Both start and end are + inclusive. Leave empty to use all rows. + + + +
+ + setDatasetSliceStart(e.target.value || null) + } + /> + + setDatasetSliceEnd(e.target.value || null) + } + /> +
+
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts index 1adfbd8b6d..cd0d1f14e1 100644 --- a/studio/frontend/src/features/training/api/mappers.ts +++ b/studio/frontend/src/features/training/api/mappers.ts @@ -4,6 +4,15 @@ import type { TrainingStartRequest } from "../types/api"; const BACKEND_LORA_TYPE = "LoRA/QLoRA"; const BACKEND_FULL_TYPE = "Full Finetuning"; +function parseSliceValue(value: string | null): number | null { + if (value == null) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const num = Number(trimmed); + if (!Number.isFinite(num) || !Number.isInteger(num)) return null; + return num; +} + export function toBackendTrainingType(trainingMethod: string): string { return trainingMethod === "full" ? BACKEND_FULL_TYPE : BACKEND_LORA_TYPE; } @@ -27,6 +36,8 @@ export function buildTrainingStartPayload( subset: hfDataset ? config.datasetSubset : null, train_split: hfDataset ? config.datasetSplit : null, eval_split: hfDataset ? config.datasetEvalSplit : null, + dataset_slice_start: parseSliceValue(config.datasetSliceStart), + dataset_slice_end: parseSliceValue(config.datasetSliceEnd), local_datasets: [], format_type: config.datasetFormat, custom_format_mapping: customFormatMapping, diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index b2d1858716..93c742ba98 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -28,6 +28,8 @@ const initialState: TrainingConfigState = { datasetSplit: null, datasetEvalSplit: null, datasetManualMapping: emptyManualMapping(), + datasetSliceStart: null, + datasetSliceEnd: null, uploadedFile: null, isCheckingVision: false, isVisionModel: false, @@ -255,6 +257,8 @@ export const useTrainingConfigStore = create()( datasetSplit: null, datasetEvalSplit: null, datasetManualMapping: emptyManualMapping(), + datasetSliceStart: null, + datasetSliceEnd: null, isDatasetMultimodal: null, isCheckingDataset: false, }); @@ -311,6 +315,8 @@ export const useTrainingConfigStore = create()( }, setDatasetManualMapping: (datasetManualMapping) => set({ datasetManualMapping }), + setDatasetSliceStart: (datasetSliceStart) => set({ datasetSliceStart }), + setDatasetSliceEnd: (datasetSliceEnd) => set({ datasetSliceEnd }), setUploadedFile: (uploadedFile) => set({ uploadedFile }), setEpochs: (epochs) => set({ epochs }), setContextLength: (contextLength) => set({ contextLength }), @@ -368,7 +374,7 @@ export const useTrainingConfigStore = create()( }, { name: "unsloth_training_config_v1", - version: 6, + version: 7, migrate: (persisted, version) => { const s = persisted as Record; if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) { @@ -387,6 +393,10 @@ export const useTrainingConfigStore = create()( if (version < 6 && s.datasetEvalSplit == null) { s.datasetEvalSplit = null; } + if (version < 7) { + s.datasetSliceStart ??= null; + s.datasetSliceEnd ??= null; + } return s as unknown as TrainingConfigStore; }, partialize: partializePersistedState, diff --git a/studio/frontend/src/features/training/types/api.ts b/studio/frontend/src/features/training/types/api.ts index 22f02e4331..e2fcc04ad4 100644 --- a/studio/frontend/src/features/training/types/api.ts +++ b/studio/frontend/src/features/training/types/api.ts @@ -8,6 +8,8 @@ export interface TrainingStartRequest { subset: string | null; train_split: string | null; eval_split: string | null; + dataset_slice_start: number | null; + dataset_slice_end: number | null; local_datasets: string[]; format_type: string; custom_format_mapping?: Record | null; diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts index 6c2feec172..0e48d18861 100644 --- a/studio/frontend/src/features/training/types/config.ts +++ b/studio/frontend/src/features/training/types/config.ts @@ -26,6 +26,8 @@ export interface TrainingConfigState { datasetSplit: string | null; datasetEvalSplit: string | null; datasetManualMapping: DatasetManualMapping; + datasetSliceStart: string | null; + datasetSliceEnd: string | null; uploadedFile: string | null; epochs: number; contextLength: number; @@ -84,6 +86,8 @@ export interface TrainingConfigActions { setDatasetSplit: (split: string | null) => void; setDatasetEvalSplit: (split: string | null) => void; setDatasetManualMapping: (mapping: DatasetManualMapping) => void; + setDatasetSliceStart: (value: string | null) => void; + setDatasetSliceEnd: (value: string | null) => void; setUploadedFile: (file: string | null) => void; setEpochs: (epochs: number) => void; setContextLength: (length: number) => void; From 5f0559926cc12ccabee0b2ddba96ecec48d35162 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 22:35:17 +0000 Subject: [PATCH 20/33] refactor: move index range fields next to eval split in 3-col grid Place Slice Start and Slice End inputs alongside the Eval Split selector in a single row (grid-cols-3) so the dataset card stays compact. Remove the duplicate controls from the Advanced section. --- .../studio/sections/dataset-section.tsx | 137 +++++++----------- .../hf-dataset-subset-split-selectors.tsx | 102 +++++++++++-- 2 files changed, 141 insertions(+), 98 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 00ee520120..338a264579 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,7 +13,6 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; -import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -291,6 +290,10 @@ export function DatasetSection() { setDatasetSplit={setDatasetSplit} datasetEvalSplit={datasetEvalSplit} setDatasetEvalSplit={setDatasetEvalSplit} + datasetSliceStart={datasetSliceStart} + setDatasetSliceStart={setDatasetSliceStart} + datasetSliceEnd={datasetSliceEnd} + setDatasetSliceEnd={setDatasetSliceEnd} /> @@ -302,93 +305,51 @@ export function DatasetSection() { Advanced -
-
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - -
-
- - Index Range - - - - - - Slice the dataset by row index. Both start and end are - inclusive. Leave empty to use all rows. - - - -
- - setDatasetSliceStart(e.target.value || null) - } - /> - - setDatasetSliceEnd(e.target.value || null) - } - /> -
-
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + +
diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index 148341e4de..35bfec8cd3 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -5,6 +5,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, @@ -31,6 +32,10 @@ type Props = { setDatasetSplit: (v: string | null) => void; datasetEvalSplit: string | null; setDatasetEvalSplit: (v: string | null) => void; + datasetSliceStart?: string | null; + setDatasetSliceStart?: (v: string | null) => void; + datasetSliceEnd?: string | null; + setDatasetSliceEnd?: (v: string | null) => void; }; export function HfDatasetSubsetSplitSelectors({ @@ -44,6 +49,10 @@ export function HfDatasetSubsetSplitSelectors({ setDatasetSplit, datasetEvalSplit, setDatasetEvalSplit, + datasetSliceStart, + setDatasetSliceStart, + datasetSliceEnd, + setDatasetSliceEnd, }: Props) { const { subsets: hfSubsets, @@ -155,16 +164,89 @@ export function HfDatasetSubsetSplitSelectors({ /> )} - + {variant === "studio" && setDatasetSliceStart && setDatasetSliceEnd ? ( +
+ +
+ + Slice Start + + + + + + Inclusive start row index. Leave empty to start from the beginning. + + + + + setDatasetSliceStart(e.target.value || null) + } + /> +
+
+ + Slice End + + + + + + Inclusive end row index. Leave empty to use all remaining rows. + + + + + setDatasetSliceEnd(e.target.value || null) + } + /> +
+
+ ) : ( + + )} )} From 8199e0d2c0ee1b6de87dcb810ec5cee5a9d9bad7 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 23:07:36 +0000 Subject: [PATCH 21/33] refactor: move train split slice controls back to Advanced section Place Train Split Start / End inputs inside the Advanced collapsible with descriptive tooltips clarifying they slice the training split. Revert the selectors component to its original eval-split-only layout. --- .../studio/sections/dataset-section.tsx | 164 ++++++++++++------ .../hf-dataset-subset-split-selectors.tsx | 102 ++--------- 2 files changed, 125 insertions(+), 141 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 338a264579..bb65f704ac 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -13,6 +13,7 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, @@ -290,10 +291,6 @@ export function DatasetSection() { setDatasetSplit={setDatasetSplit} datasetEvalSplit={datasetEvalSplit} setDatasetEvalSplit={setDatasetEvalSplit} - datasetSliceStart={datasetSliceStart} - setDatasetSliceStart={setDatasetSliceStart} - datasetSliceEnd={datasetSliceEnd} - setDatasetSliceEnd={setDatasetSliceEnd} /> @@ -305,51 +302,120 @@ export function DatasetSection() { Advanced -
- - Target Format - - - - - - Format of your training data. Auto-detect works for most - datasets.{" "} - - Read more - - - - - +
+
+ + Target Format + + + + + + Format of your training data. Auto-detect works for most + datasets.{" "} + + Read more + + + + + +
+
+
+ + Train Split Start + + + + + + Only train on a subset of your training split by + specifying a start row index (inclusive, 0-based). + Useful for resuming from a checkpoint or debugging + with a smaller slice. Leave empty to start from the + first row. + + + + + setDatasetSliceStart(e.target.value || null) + } + /> +
+
+ + Train Split End + + + + + + Last row index to include from the training split + (inclusive, 0-based). For example, set Start to 0 and + End to 99 to train on the first 100 rows. Leave empty + to use all remaining rows. + + + + + setDatasetSliceEnd(e.target.value || null) + } + /> +
+
diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index 35bfec8cd3..148341e4de 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -5,7 +5,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, @@ -32,10 +31,6 @@ type Props = { setDatasetSplit: (v: string | null) => void; datasetEvalSplit: string | null; setDatasetEvalSplit: (v: string | null) => void; - datasetSliceStart?: string | null; - setDatasetSliceStart?: (v: string | null) => void; - datasetSliceEnd?: string | null; - setDatasetSliceEnd?: (v: string | null) => void; }; export function HfDatasetSubsetSplitSelectors({ @@ -49,10 +44,6 @@ export function HfDatasetSubsetSplitSelectors({ setDatasetSplit, datasetEvalSplit, setDatasetEvalSplit, - datasetSliceStart, - setDatasetSliceStart, - datasetSliceEnd, - setDatasetSliceEnd, }: Props) { const { subsets: hfSubsets, @@ -164,89 +155,16 @@ export function HfDatasetSubsetSplitSelectors({ /> )} - {variant === "studio" && setDatasetSliceStart && setDatasetSliceEnd ? ( -
- -
- - Slice Start - - - - - - Inclusive start row index. Leave empty to start from the beginning. - - - - - setDatasetSliceStart(e.target.value || null) - } - /> -
-
- - Slice End - - - - - - Inclusive end row index. Leave empty to use all remaining rows. - - - - - setDatasetSliceEnd(e.target.value || null) - } - /> -
-
- ) : ( - - )} + )} From 40f2dc517fae1a434f14841e4151dd5b80e50f4e Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 23:15:49 +0000 Subject: [PATCH 22/33] fix: remove unnecessary tooltip copy from train split start --- .../frontend/src/features/studio/sections/dataset-section.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index bb65f704ac..28254adcc7 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -368,9 +368,7 @@ export function DatasetSection() { Only train on a subset of your training split by specifying a start row index (inclusive, 0-based). - Useful for resuming from a checkpoint or debugging - with a smaller slice. Leave empty to start from the - first row. + Leave empty to start from the first row. From 929c3e9e1edbeb709ac0f51aa5eaf5bc2b062689 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 06:42:37 +0000 Subject: [PATCH 23/33] fix: cast URL image columns to HF Image() type in VLM conversion --- studio/backend/utils/datasets/format_conversion.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index 6436e7a82a..4e6a6d9919 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -254,8 +254,15 @@ def convert_to_vlm_format( list: List of dicts with 'messages' field """ from PIL import Image + from datasets import Image as datasets_Image from .vlm_processing import generate_smart_vlm_instruction + # Cast string image columns (URLs or local paths) to HF Image() type + # so HuggingFace handles downloading, decoding, and caching transparently. + sample_value = next(iter(dataset))[image_column] + if isinstance(sample_value, str): + dataset = dataset.cast_column(image_column, datasets_Image()) + # Generate smart instruction if not provided if instruction is None: instruction_info = generate_smart_vlm_instruction( From 2b704221f7add28d0796229c77ce3256a917093b Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 06:42:48 +0000 Subject: [PATCH 24/33] fix: abort training pipeline on dataset conversion failure --- studio/backend/core/training/trainer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 5ce273f43c..f5b2f18245 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -482,6 +482,14 @@ class UnslothTrainer: print("Stopped during dataset formatting\n") return None + # Abort if dataset formatting/conversion failed + if not dataset_info.get("success", True): + errors = dataset_info.get("errors", []) + error_msg = "; ".join(errors) if errors else "Dataset formatting failed" + logger.error(f"Dataset conversion failed: {error_msg}") + self._update_progress(error=error_msg) + return None + self._update_progress(status_message=f"Dataset formatted and ready for training") print(f"Dataset formatted successfully\n") From 8039eebcd5fb7a0158a5c94d55a742da344e3795 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 07:39:35 +0000 Subject: [PATCH 25/33] test: add URL image loading comparison script --- studio/tests/test_url_image_loading.py | 132 +++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 studio/tests/test_url_image_loading.py diff --git a/studio/tests/test_url_image_loading.py b/studio/tests/test_url_image_loading.py new file mode 100644 index 0000000000..4899bab8e3 --- /dev/null +++ b/studio/tests/test_url_image_loading.py @@ -0,0 +1,132 @@ +""" +Reproduce: VLM URL image loading with HF datasets. +Tests cast_column(Image()) vs manual download approaches. +Dataset: google-research-datasets/conceptual_captions (subset: labeled) +""" +from datasets import load_dataset, Image as datasets_Image, Dataset +from PIL import Image as PILImage +from io import BytesIO +from itertools import islice +import time + +DATASET = "google-research-datasets/conceptual_captions" +SUBSET = "labeled" +SPLIT = "train" +N_SAMPLES = 20 # small slice for testing + +print("=" * 60) +print("Loading dataset (streaming, first N samples)...") +print("=" * 60) +ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) +rows = list(islice(ds, N_SAMPLES)) +dataset = Dataset.from_list(rows) + +print(f"Loaded {len(dataset)} samples") +print(f"Columns: {dataset.column_names}") +print(f"First image_url: {dataset[0]['image_url'][:100]}...") +print() + +# ─── Test 1: cast_column(Image()) — what we tried ─── +print("=" * 60) +print("TEST 1: cast_column(Image()) approach") +print("=" * 60) +try: + ds_cast = dataset.cast_column("image_url", datasets_Image()) + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(ds_cast): + try: + img = sample["image_url"] + if img is not None: + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + else: + print(f" [{i}] None returned") + fail += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED during iteration: {type(e).__name__}: {str(e)[:120]}") +print() + +# ─── Test 2: Manual download with requests.Session ─── +print("=" * 60) +print("TEST 2: requests.Session() approach") +print("=" * 60) +try: + import requests + session = requests.Session() + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(dataset): + url = sample["image_url"] + try: + resp = session.get(url, timeout=10) + resp.raise_for_status() + img = PILImage.open(BytesIO(resp.content)).convert("RGB") + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") +print() + +# ─── Test 3: urllib (stdlib) ─── +print("=" * 60) +print("TEST 3: urllib approach (stdlib)") +print("=" * 60) +try: + from urllib.request import urlopen, Request + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(dataset): + url = sample["image_url"] + try: + req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urlopen(req, timeout=10) as resp: + img = PILImage.open(BytesIO(resp.read())).convert("RGB") + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") +print() + +# ─── Test 4: fsspec directly with expand=True ─── +print("=" * 60) +print("TEST 4: fsspec.open() with expand=True") +print("=" * 60) +try: + import fsspec + success, fail = 0, 0 + t0 = time.time() + for i, sample in enumerate(dataset): + url = sample["image_url"] + try: + with fsspec.open(url, "rb", expand=True) as f: + img = PILImage.open(BytesIO(f.read())).convert("RGB") + print(f" [{i}] OK — {img.size} {img.mode}") + success += 1 + except Exception as e: + print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") + fail += 1 + elapsed = time.time() - t0 + print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") +except Exception as e: + print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") + +print() +print("=" * 60) +print("DONE — compare success rates and timing above") +print("=" * 60) From fdc23f4a43d0f77d9ba10ba88aabd88532ff09a6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 07:50:55 +0000 Subject: [PATCH 26/33] fix: use fsspec for URL image downloads with per-sample error handling --- .../utils/datasets/format_conversion.py | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index 4e6a6d9919..37bd3c103c 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -254,15 +254,8 @@ def convert_to_vlm_format( list: List of dicts with 'messages' field """ from PIL import Image - from datasets import Image as datasets_Image from .vlm_processing import generate_smart_vlm_instruction - # Cast string image columns (URLs or local paths) to HF Image() type - # so HuggingFace handles downloading, decoding, and caching transparently. - sample_value = next(iter(dataset))[image_column] - if isinstance(sample_value, str): - dataset = dataset.cast_column(image_column, datasets_Image()) - # Generate smart instruction if not provided if instruction is None: instruction_info = generate_smart_vlm_instruction( @@ -288,12 +281,17 @@ def convert_to_vlm_format( def _convert_single_sample(sample): """Convert a single sample to VLM format.""" - # Get image (might be PIL Image or path) + # Get image (might be PIL Image, local path, or URL) image_data = sample[image_column] - # Handle image paths if isinstance(image_data, str): - image_data = Image.open(image_data).convert("RGB") + if image_data.startswith(("http://", "https://")): + import fsspec + from io import BytesIO + with fsspec.open(image_data, "rb", expand=True) as f: + image_data = Image.open(BytesIO(f.read())).convert("RGB") + else: + image_data = Image.open(image_data).convert("RGB") # Get text text_data = sample[text_column] @@ -324,11 +322,35 @@ def convert_to_vlm_format( # Return dict with messages return {"messages": messages} - # Use list comprehension and return the LIST directly - print(f"🔄 Converting {len(dataset)} samples to VLM format...") - converted_list = [_convert_single_sample(sample) for sample in dataset] + # Convert samples, skipping any with broken/unreachable images + total = len(dataset) + print(f"🔄 Converting {total} samples to VLM format...") + converted_list = [] + failed_count = 0 + for sample in dataset: + try: + converted_list.append(_convert_single_sample(sample)) + except Exception as e: + failed_count += 1 - print(f"✅ Converted {len(converted_list)} samples") + if failed_count > 0: + fail_rate = failed_count / total + print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images") + + if fail_rate >= 0.3: + raise ValueError( + f"{fail_rate:.0%} of images failed to download ({failed_count}/{total}). " + "This dataset has too many broken or unreachable image URLs to be usable for training. " + "Consider using a dataset with embedded images instead." + ) + + if len(converted_list) == 0: + raise ValueError( + f"All {total} samples failed during VLM conversion — no usable images found. " + "This dataset may contain only image URLs that are no longer accessible." + ) + + print(f"✅ Converted {len(converted_list)}/{total} samples") # Return list, NOT Dataset return converted_list From 50885a7aa3cb376c1f59c1f3128575b08c18d55e Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 08:05:40 +0000 Subject: [PATCH 27/33] fix: add early probe to fail fast on datasets with too many broken image URLs --- .../utils/datasets/format_conversion.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index 37bd3c103c..f9da8b1a1e 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -322,28 +322,42 @@ def convert_to_vlm_format( # Return dict with messages return {"messages": messages} - # Convert samples, skipping any with broken/unreachable images + # Convert samples, skipping any with broken/unreachable images. + # For URL-based datasets, check the first PROBE_SIZE samples early to + # fail fast if too many images are broken, before downloading millions. + PROBE_SIZE = 5000 + MAX_FAIL_RATE = 0.3 + total = len(dataset) + has_urls = isinstance(next(iter(dataset))[image_column], str) + probe_needed = has_urls and total > PROBE_SIZE + print(f"🔄 Converting {total} samples to VLM format...") converted_list = [] failed_count = 0 - for sample in dataset: + + for i, sample in enumerate(dataset): try: converted_list.append(_convert_single_sample(sample)) except Exception as e: failed_count += 1 + # Early exit check after probing the first batch + if probe_needed and (i + 1) == PROBE_SIZE: + fail_rate = failed_count / PROBE_SIZE + if fail_rate >= MAX_FAIL_RATE: + raise ValueError( + f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " + f"({failed_count}/{PROBE_SIZE}). " + "This dataset has too many broken or unreachable image URLs. " + "Consider using a dataset with embedded images instead." + ) + print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...") + if failed_count > 0: fail_rate = failed_count / total print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images") - if fail_rate >= 0.3: - raise ValueError( - f"{fail_rate:.0%} of images failed to download ({failed_count}/{total}). " - "This dataset has too many broken or unreachable image URLs to be usable for training. " - "Consider using a dataset with embedded images instead." - ) - if len(converted_list) == 0: raise ValueError( f"All {total} samples failed during VLM conversion — no usable images found. " From f59eaad212789ba1cc4da15274ee9d2c6fc680a7 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 13:30:27 +0000 Subject: [PATCH 28/33] feat: add tqdm progress bar to VLM conversion and download benchmark test --- .../utils/datasets/format_conversion.py | 9 +++- studio/tests/test_url_download_benchmark.py | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 studio/tests/test_url_download_benchmark.py diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index f9da8b1a1e..e784e8e645 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -332,20 +332,26 @@ def convert_to_vlm_format( has_urls = isinstance(next(iter(dataset))[image_column], str) probe_needed = has_urls and total > PROBE_SIZE + from tqdm import tqdm + print(f"🔄 Converting {total} samples to VLM format...") converted_list = [] failed_count = 0 - for i, sample in enumerate(dataset): + pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample") + for i, sample in enumerate(pbar): try: converted_list.append(_convert_single_sample(sample)) except Exception as e: failed_count += 1 + pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False) + # Early exit check after probing the first batch if probe_needed and (i + 1) == PROBE_SIZE: fail_rate = failed_count / PROBE_SIZE if fail_rate >= MAX_FAIL_RATE: + pbar.close() raise ValueError( f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " f"({failed_count}/{PROBE_SIZE}). " @@ -353,6 +359,7 @@ def convert_to_vlm_format( "Consider using a dataset with embedded images instead." ) print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...") + pbar.close() if failed_count > 0: fail_rate = failed_count / total diff --git a/studio/tests/test_url_download_benchmark.py b/studio/tests/test_url_download_benchmark.py new file mode 100644 index 0000000000..e29a8e05b6 --- /dev/null +++ b/studio/tests/test_url_download_benchmark.py @@ -0,0 +1,54 @@ +""" +Benchmark: fsspec URL image download throughput at different dataset sizes. +Dataset: google-research-datasets/conceptual_captions (subset: labeled) + +Tests sizes: 100, 200, 300, 500, 1000, 1500, 2000 +Reports: time, success/fail rate, throughput (images/sec) +""" +from datasets import load_dataset, Dataset +from PIL import Image as PILImage +from io import BytesIO +from itertools import islice +import fsspec +import time + +DATASET = "google-research-datasets/conceptual_captions" +SUBSET = "labeled" +SPLIT = "train" +SIZES = [100, 200, 300, 500, 1000, 1500, 2000] + +# Load the max we need in one go +max_size = max(SIZES) +print(f"Loading {max_size} samples from {DATASET} (streaming)...") +ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) +rows = list(islice(ds, max_size)) +full_dataset = Dataset.from_list(rows) +print(f"Loaded {len(full_dataset)} samples") +print(f"Columns: {full_dataset.column_names}") +print() + +print(f"{'Size':>6} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7}") +print("-" * 55) + +for size in SIZES: + dataset = full_dataset.select(range(size)) + success, fail = 0, 0 + t0 = time.time() + + for sample in dataset: + url = sample["image_url"] + try: + with fsspec.open(url, "rb", expand=True) as f: + img = PILImage.open(BytesIO(f.read())).convert("RGB") + success += 1 + except Exception: + fail += 1 + + elapsed = time.time() - t0 + fail_pct = (fail / size) * 100 + throughput = success / elapsed if elapsed > 0 else 0 + + print(f"{size:>6} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s") + +print() +print("Done.") From 195c1a3ce38459422880665d6dd33c97e32242e8 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 14:30:11 +0000 Subject: [PATCH 29/33] test: add parallel download benchmark with ThreadPoolExecutor --- studio/tests/test_url_parallel_benchmark.py | 79 +++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 studio/tests/test_url_parallel_benchmark.py diff --git a/studio/tests/test_url_parallel_benchmark.py b/studio/tests/test_url_parallel_benchmark.py new file mode 100644 index 0000000000..a0d160136c --- /dev/null +++ b/studio/tests/test_url_parallel_benchmark.py @@ -0,0 +1,79 @@ +""" +Benchmark: parallel fsspec URL image downloads with ThreadPoolExecutor. +Tests different worker counts to find optimal parallelism. +Dataset: google-research-datasets/conceptual_captions (subset: labeled) +""" +from datasets import load_dataset, Dataset +from PIL import Image as PILImage +from io import BytesIO +from itertools import islice +from concurrent.futures import ThreadPoolExecutor, as_completed +import fsspec +import time +import os + +DATASET = "google-research-datasets/conceptual_captions" +SUBSET = "labeled" +SPLIT = "train" +N_SAMPLES = 500 + +# safe_num_proc formula from studio/backend/utils/hardware/hardware.py +cpu_count = os.cpu_count() +safe_workers = max(1, cpu_count // 3) +print(f"CPU count: {cpu_count}, safe_num_proc: {safe_workers}") + +WORKER_COUNTS = [1, 4, 8, 16, 32, safe_workers] +# Deduplicate and sort +WORKER_COUNTS = sorted(set(WORKER_COUNTS)) + +print(f"Loading {N_SAMPLES} samples from {DATASET} (streaming)...") +ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) +rows = list(islice(ds, N_SAMPLES)) +dataset = Dataset.from_list(rows) +urls = [row["image_url"] for row in dataset] +print(f"Loaded {len(urls)} URLs") +print() + + +def download_single(url): + """Download a single image URL using fsspec. Returns PIL image or raises.""" + with fsspec.open(url, "rb", expand=True) as f: + img = PILImage.open(BytesIO(f.read())).convert("RGB") + return img + + +print(f"{'Workers':>8} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7} | {'Speedup':>8}") +print("-" * 70) + +baseline_throughput = None + +for n_workers in WORKER_COUNTS: + success, fail = 0, 0 + t0 = time.time() + + with ThreadPoolExecutor(max_workers=n_workers) as pool: + futures = {pool.submit(download_single, url): url for url in urls} + for future in as_completed(futures): + try: + img = future.result(timeout=30) + success += 1 + except Exception: + fail += 1 + + elapsed = time.time() - t0 + fail_pct = (fail / N_SAMPLES) * 100 + throughput = success / elapsed if elapsed > 0 else 0 + + if baseline_throughput is None: + baseline_throughput = throughput + speedup = throughput / baseline_throughput if baseline_throughput > 0 else 0 + + label = f"{n_workers}" + if n_workers == safe_workers: + label += "*" # mark the safe_num_proc value + + print(f"{label:>8} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s | {speedup:>7.1f}x") + +print() +print("* = safe_num_proc value") +print("Done.") From 9ca45826d4ebdb9972df255696b69e3d45d9846a Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 4 Mar 2026 23:40:38 +0000 Subject: [PATCH 30/33] feat: parallel URL image probe with time estimate and progress reporting - Add 200-sample parallel probe using ThreadPoolExecutor + safe_num_proc to estimate download speed and failure rate before full conversion - Abort with clear error if >=30% of probe images fail to download - Show estimated download time in the training overlay modal - Parallel batch conversion for URL-based datasets (vs sequential for local) - Add warning field to /check-format response for URL-based image datasets - Display URL warning in dataset preview dialog (amber banner) - Thread progress_callback from trainer through format_and_template_dataset to convert_to_vlm_format for real-time status updates --- studio/backend/core/training/trainer.py | 1 + studio/backend/models/datasets.py | 1 + studio/backend/routes/datasets.py | 16 ++ .../backend/utils/datasets/dataset_utils.py | 3 + .../utils/datasets/format_conversion.py | 167 +++++++++++++++--- .../sections/dataset-preview-dialog.tsx | 7 + .../src/features/training/types/datasets.ts | 1 + 7 files changed, 169 insertions(+), 27 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index f5b2f18245..f2e43f76f4 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -475,6 +475,7 @@ class UnslothTrainer: format_type=format_type, dataset_name=dataset_source, custom_format_mapping=custom_format_mapping, + progress_callback=self._update_progress, ) # Check if stopped during formatting diff --git a/studio/backend/models/datasets.py b/studio/backend/models/datasets.py index 81adef7577..18f6ec224b 100644 --- a/studio/backend/models/datasets.py +++ b/studio/backend/models/datasets.py @@ -34,3 +34,4 @@ class CheckFormatResponse(BaseModel): detected_text_column: Optional[str] = None preview_samples: Optional[List[Dict]] = None total_rows: Optional[int] = None + warning: Optional[str] = None diff --git a/studio/backend/routes/datasets.py b/studio/backend/routes/datasets.py index a53e223015..4a475ff8c2 100644 --- a/studio/backend/routes/datasets.py +++ b/studio/backend/routes/datasets.py @@ -211,6 +211,21 @@ def check_format( else: preview_samples = _serialize_preview_rows(preview_slice) + # Lightweight URL-based image detection for VLM datasets + warning = None + image_col = result.get("detected_image_column") + if image_col and image_col in (result.get("columns") or []): + try: + sample_val = preview_slice[0][image_col] + if isinstance(sample_val, str) and sample_val.startswith(("http://", "https://")): + warning = ( + "This dataset contains image URLs instead of embedded images. " + "Images will be downloaded during training, which may be slow for large datasets." + ) + logger.info(f"URL-based image column detected: {image_col}") + except Exception: + pass + return CheckFormatResponse( requires_manual_mapping=result["requires_manual_mapping"], detected_format=result["detected_format"], @@ -222,6 +237,7 @@ def check_format( detected_text_column=result.get("detected_text_column"), preview_samples=preview_samples, total_rows=total_rows, + warning=warning, ) except HTTPException: diff --git a/studio/backend/utils/datasets/dataset_utils.py b/studio/backend/utils/datasets/dataset_utils.py index 3a4d54f93f..9e1f54a75c 100644 --- a/studio/backend/utils/datasets/dataset_utils.py +++ b/studio/backend/utils/datasets/dataset_utils.py @@ -593,6 +593,7 @@ def format_and_template_dataset( aliases_for_assistant=["gpt", "assistant", "output",], batch_size=1000, num_proc=None, + progress_callback=None, ): """ Convenience function that combines format_dataset and apply_chat_template_to_dataset. @@ -638,6 +639,7 @@ def format_and_template_dataset( text_column=user_vlm_text_column, image_column=user_vlm_image_column, dataset_name=dataset_name, + progress_callback=progress_callback, ) warnings.append(f"Applied user VLM mapping: image='{user_vlm_image_column}', text='{user_vlm_text_column}'") @@ -734,6 +736,7 @@ def format_and_template_dataset( text_column=vlm_text_column, image_column=vlm_image_column, dataset_name=dataset_name, + progress_callback=progress_callback, ) if vlm_instruction: diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index e784e8e645..c5c9a4d6e7 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -238,24 +238,51 @@ def convert_alpaca_to_chatml(dataset, batch_size=1000, num_proc=None): return dataset.map(_convert, **dataset_map_kwargs) +def _format_eta(seconds): + """Format seconds into a human-readable ETA string.""" + if seconds < 60: + return f"{seconds:.0f}s" + elif seconds < 3600: + m, s = divmod(int(seconds), 60) + return f"{m}m {s}s" + else: + h, remainder = divmod(int(seconds), 3600) + m, _ = divmod(remainder, 60) + return f"{h}h {m}m" + + def convert_to_vlm_format( dataset, instruction=None, text_column="text", image_column="image", dataset_name=None, + progress_callback=None, ): """ Converts simple {image, text} format to VLM messages format. Returns a LIST, not a HuggingFace Dataset (to preserve PIL Images). + For URL-based image datasets, runs a 200-sample parallel probe first to + estimate download speed and failure rate, then reports time estimate or + warning through progress_callback before proceeding with the full conversion. + + Args: + progress_callback: Optional callable(status_message=str) to report + progress to the training overlay. + Returns: list: List of dicts with 'messages' field """ from PIL import Image from .vlm_processing import generate_smart_vlm_instruction + def _notify(msg): + """Send status update to the training overlay if callback is available.""" + if progress_callback: + progress_callback(status_message=msg) + # Generate smart instruction if not provided if instruction is None: instruction_info = generate_smart_vlm_instruction( @@ -322,48 +349,133 @@ def convert_to_vlm_format( # Return dict with messages return {"messages": messages} - # Convert samples, skipping any with broken/unreachable images. - # For URL-based datasets, check the first PROBE_SIZE samples early to - # fail fast if too many images are broken, before downloading millions. - PROBE_SIZE = 5000 - MAX_FAIL_RATE = 0.3 - total = len(dataset) has_urls = isinstance(next(iter(dataset))[image_column], str) - probe_needed = has_urls and total > PROBE_SIZE + # ── URL probe: 200 samples with parallel workers to estimate speed + failure rate ── + PROBE_SIZE = 200 + MAX_FAIL_RATE = 0.3 + + if has_urls and total > PROBE_SIZE: + import time + from concurrent.futures import ThreadPoolExecutor, as_completed + from utils.hardware import safe_num_proc + + num_workers = safe_num_proc() + _notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...") + print(f"🔍 Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers...") + + probe_samples = [dataset[i] for i in range(PROBE_SIZE)] + probe_ok = 0 + probe_fail = 0 + probe_start = time.time() + + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = {executor.submit(_convert_single_sample, s): s for s in probe_samples} + for future in as_completed(futures): + try: + future.result() + probe_ok += 1 + except Exception: + probe_fail += 1 + + probe_elapsed = time.time() - probe_start + probe_total = probe_ok + probe_fail + fail_rate = probe_fail / probe_total if probe_total > 0 else 0 + throughput = probe_total / probe_elapsed if probe_elapsed > 0 else 0 + + if fail_rate >= MAX_FAIL_RATE: + msg = ( + f"⚠️ {fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " + f"({probe_fail}/{probe_total}). " + "This dataset has too many broken or unreachable image URLs. " + "Consider using a dataset with embedded images instead." + ) + print(msg) + _notify(msg) + raise ValueError(msg) + + # Estimate total time for remaining samples + remaining = total - PROBE_SIZE + estimated_seconds = remaining / throughput if throughput > 0 else 0 + eta_str = _format_eta(estimated_seconds) + + info_msg = ( + f"Downloading {total:,} images ({num_workers} workers, ~{throughput:.1f} img/s). " + f"Estimated time: ~{eta_str}" + ) + if probe_fail > 0: + info_msg += f" | {fail_rate:.0%} broken URLs will be skipped" + + print(f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s") + print(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}") + _notify(info_msg) + + # ── Full conversion with progress ── from tqdm import tqdm print(f"🔄 Converting {total} samples to VLM format...") converted_list = [] failed_count = 0 - pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample") - for i, sample in enumerate(pbar): - try: - converted_list.append(_convert_single_sample(sample)) - except Exception as e: - failed_count += 1 + if has_urls: + # Parallel conversion for URL-based datasets + import time + from concurrent.futures import ThreadPoolExecutor, as_completed + from utils.hardware import safe_num_proc - pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False) + num_workers = safe_num_proc() + batch_size = 500 + start_time = time.time() - # Early exit check after probing the first batch - if probe_needed and (i + 1) == PROBE_SIZE: - fail_rate = failed_count / PROBE_SIZE - if fail_rate >= MAX_FAIL_RATE: - pbar.close() - raise ValueError( - f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " - f"({failed_count}/{PROBE_SIZE}). " - "This dataset has too many broken or unreachable image URLs. " - "Consider using a dataset with embedded images instead." - ) - print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...") - pbar.close() + for batch_start in range(0, total, batch_size): + batch_end = min(batch_start + batch_size, total) + batch_samples = [dataset[i] for i in range(batch_start, batch_end)] + + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = {executor.submit(_convert_single_sample, s): i for i, s in enumerate(batch_samples)} + batch_results = [None] * len(batch_samples) + for future in as_completed(futures): + idx = futures[future] + try: + batch_results[idx] = future.result() + except Exception: + failed_count += 1 + + converted_list.extend(r for r in batch_results if r is not None) + + # Progress update every batch + elapsed = time.time() - start_time + done = batch_end + rate = done / elapsed if elapsed > 0 else 0 + remaining_time = (total - done) / rate if rate > 0 else 0 + eta_str = _format_eta(remaining_time) + progress_msg = f"Downloading images: {done:,}/{total:,} ({done*100//total}%) | ~{eta_str} remaining | {failed_count} skipped" + print(f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}") + _notify(progress_msg) + else: + # Sequential conversion for local/embedded images (fast, no I/O bottleneck) + pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample") + for sample in pbar: + try: + converted_list.append(_convert_single_sample(sample)) + except Exception: + failed_count += 1 + pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False) + pbar.close() if failed_count > 0: fail_rate = failed_count / total print(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images") + # For datasets that skipped the probe (small URL datasets), check fail rate now + if has_urls and fail_rate >= MAX_FAIL_RATE: + msg = ( + f"⚠️ {fail_rate:.0%} of images failed to download ({failed_count}/{total}). " + "This dataset has too many broken or unreachable image URLs. " + "Consider using a dataset with embedded images instead." + ) + _notify(msg) + raise ValueError(msg) if len(converted_list) == 0: raise ValueError( @@ -372,6 +484,7 @@ def convert_to_vlm_format( ) print(f"✅ Converted {len(converted_list)}/{total} samples") + _notify(f"Converted {len(converted_list):,}/{total:,} images successfully") # Return list, NOT Dataset return converted_list diff --git a/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx b/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx index 00a738bc64..516f3612c0 100644 --- a/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-preview-dialog.tsx @@ -340,6 +340,13 @@ export function DatasetPreviewDialog({ />
+ {data.warning && ( +
+ + {data.warning} +
+ )} + {mappingEnabled && ( Date: Wed, 4 Mar 2026 23:42:23 +0000 Subject: [PATCH 31/33] fix: clear dataset slice state when switching to uploaded file Prevents stale slice values from silently truncating uploaded datasets. --- .../src/features/training/stores/training-config-store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index 93c742ba98..8aeb54af78 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -317,7 +317,8 @@ export const useTrainingConfigStore = create()( set({ datasetManualMapping }), setDatasetSliceStart: (datasetSliceStart) => set({ datasetSliceStart }), setDatasetSliceEnd: (datasetSliceEnd) => set({ datasetSliceEnd }), - setUploadedFile: (uploadedFile) => set({ uploadedFile }), + setUploadedFile: (uploadedFile) => + set({ uploadedFile, datasetSliceStart: null, datasetSliceEnd: null }), setEpochs: (epochs) => set({ epochs }), setContextLength: (contextLength) => set({ contextLength }), setLearningRate: (learningRate) => set({ learningRate }), From 657cdaa1514c0151de98ee1c2f837f36cc5beb15 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Thu, 5 Mar 2026 06:06:47 +0000 Subject: [PATCH 32/33] fix: remove benchmark scripts from git tracking These are standalone benchmark scripts that were force-added despite being gitignored. They have no test functions and run network calls at module level, which breaks pytest collection in CI. --- studio/tests/test_url_download_benchmark.py | 54 -------- studio/tests/test_url_image_loading.py | 132 -------------------- studio/tests/test_url_parallel_benchmark.py | 79 ------------ 3 files changed, 265 deletions(-) delete mode 100644 studio/tests/test_url_download_benchmark.py delete mode 100644 studio/tests/test_url_image_loading.py delete mode 100644 studio/tests/test_url_parallel_benchmark.py diff --git a/studio/tests/test_url_download_benchmark.py b/studio/tests/test_url_download_benchmark.py deleted file mode 100644 index e29a8e05b6..0000000000 --- a/studio/tests/test_url_download_benchmark.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Benchmark: fsspec URL image download throughput at different dataset sizes. -Dataset: google-research-datasets/conceptual_captions (subset: labeled) - -Tests sizes: 100, 200, 300, 500, 1000, 1500, 2000 -Reports: time, success/fail rate, throughput (images/sec) -""" -from datasets import load_dataset, Dataset -from PIL import Image as PILImage -from io import BytesIO -from itertools import islice -import fsspec -import time - -DATASET = "google-research-datasets/conceptual_captions" -SUBSET = "labeled" -SPLIT = "train" -SIZES = [100, 200, 300, 500, 1000, 1500, 2000] - -# Load the max we need in one go -max_size = max(SIZES) -print(f"Loading {max_size} samples from {DATASET} (streaming)...") -ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) -rows = list(islice(ds, max_size)) -full_dataset = Dataset.from_list(rows) -print(f"Loaded {len(full_dataset)} samples") -print(f"Columns: {full_dataset.column_names}") -print() - -print(f"{'Size':>6} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7}") -print("-" * 55) - -for size in SIZES: - dataset = full_dataset.select(range(size)) - success, fail = 0, 0 - t0 = time.time() - - for sample in dataset: - url = sample["image_url"] - try: - with fsspec.open(url, "rb", expand=True) as f: - img = PILImage.open(BytesIO(f.read())).convert("RGB") - success += 1 - except Exception: - fail += 1 - - elapsed = time.time() - t0 - fail_pct = (fail / size) * 100 - throughput = success / elapsed if elapsed > 0 else 0 - - print(f"{size:>6} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s") - -print() -print("Done.") diff --git a/studio/tests/test_url_image_loading.py b/studio/tests/test_url_image_loading.py deleted file mode 100644 index 4899bab8e3..0000000000 --- a/studio/tests/test_url_image_loading.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Reproduce: VLM URL image loading with HF datasets. -Tests cast_column(Image()) vs manual download approaches. -Dataset: google-research-datasets/conceptual_captions (subset: labeled) -""" -from datasets import load_dataset, Image as datasets_Image, Dataset -from PIL import Image as PILImage -from io import BytesIO -from itertools import islice -import time - -DATASET = "google-research-datasets/conceptual_captions" -SUBSET = "labeled" -SPLIT = "train" -N_SAMPLES = 20 # small slice for testing - -print("=" * 60) -print("Loading dataset (streaming, first N samples)...") -print("=" * 60) -ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) -rows = list(islice(ds, N_SAMPLES)) -dataset = Dataset.from_list(rows) - -print(f"Loaded {len(dataset)} samples") -print(f"Columns: {dataset.column_names}") -print(f"First image_url: {dataset[0]['image_url'][:100]}...") -print() - -# ─── Test 1: cast_column(Image()) — what we tried ─── -print("=" * 60) -print("TEST 1: cast_column(Image()) approach") -print("=" * 60) -try: - ds_cast = dataset.cast_column("image_url", datasets_Image()) - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(ds_cast): - try: - img = sample["image_url"] - if img is not None: - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - else: - print(f" [{i}] None returned") - fail += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED during iteration: {type(e).__name__}: {str(e)[:120]}") -print() - -# ─── Test 2: Manual download with requests.Session ─── -print("=" * 60) -print("TEST 2: requests.Session() approach") -print("=" * 60) -try: - import requests - session = requests.Session() - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(dataset): - url = sample["image_url"] - try: - resp = session.get(url, timeout=10) - resp.raise_for_status() - img = PILImage.open(BytesIO(resp.content)).convert("RGB") - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") -print() - -# ─── Test 3: urllib (stdlib) ─── -print("=" * 60) -print("TEST 3: urllib approach (stdlib)") -print("=" * 60) -try: - from urllib.request import urlopen, Request - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(dataset): - url = sample["image_url"] - try: - req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) - with urlopen(req, timeout=10) as resp: - img = PILImage.open(BytesIO(resp.read())).convert("RGB") - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") -print() - -# ─── Test 4: fsspec directly with expand=True ─── -print("=" * 60) -print("TEST 4: fsspec.open() with expand=True") -print("=" * 60) -try: - import fsspec - success, fail = 0, 0 - t0 = time.time() - for i, sample in enumerate(dataset): - url = sample["image_url"] - try: - with fsspec.open(url, "rb", expand=True) as f: - img = PILImage.open(BytesIO(f.read())).convert("RGB") - print(f" [{i}] OK — {img.size} {img.mode}") - success += 1 - except Exception as e: - print(f" [{i}] FAILED: {type(e).__name__}: {str(e)[:80]}") - fail += 1 - elapsed = time.time() - t0 - print(f"\nResult: {success} ok, {fail} failed, {elapsed:.1f}s") -except Exception as e: - print(f"CRASHED: {type(e).__name__}: {str(e)[:120]}") - -print() -print("=" * 60) -print("DONE — compare success rates and timing above") -print("=" * 60) diff --git a/studio/tests/test_url_parallel_benchmark.py b/studio/tests/test_url_parallel_benchmark.py deleted file mode 100644 index a0d160136c..0000000000 --- a/studio/tests/test_url_parallel_benchmark.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Benchmark: parallel fsspec URL image downloads with ThreadPoolExecutor. -Tests different worker counts to find optimal parallelism. -Dataset: google-research-datasets/conceptual_captions (subset: labeled) -""" -from datasets import load_dataset, Dataset -from PIL import Image as PILImage -from io import BytesIO -from itertools import islice -from concurrent.futures import ThreadPoolExecutor, as_completed -import fsspec -import time -import os - -DATASET = "google-research-datasets/conceptual_captions" -SUBSET = "labeled" -SPLIT = "train" -N_SAMPLES = 500 - -# safe_num_proc formula from studio/backend/utils/hardware/hardware.py -cpu_count = os.cpu_count() -safe_workers = max(1, cpu_count // 3) -print(f"CPU count: {cpu_count}, safe_num_proc: {safe_workers}") - -WORKER_COUNTS = [1, 4, 8, 16, 32, safe_workers] -# Deduplicate and sort -WORKER_COUNTS = sorted(set(WORKER_COUNTS)) - -print(f"Loading {N_SAMPLES} samples from {DATASET} (streaming)...") -ds = load_dataset(DATASET, name=SUBSET, split=SPLIT, streaming=True) -rows = list(islice(ds, N_SAMPLES)) -dataset = Dataset.from_list(rows) -urls = [row["image_url"] for row in dataset] -print(f"Loaded {len(urls)} URLs") -print() - - -def download_single(url): - """Download a single image URL using fsspec. Returns PIL image or raises.""" - with fsspec.open(url, "rb", expand=True) as f: - img = PILImage.open(BytesIO(f.read())).convert("RGB") - return img - - -print(f"{'Workers':>8} | {'Time':>8} | {'OK':>6} | {'Fail':>6} | {'Fail%':>6} | {'img/s':>7} | {'Speedup':>8}") -print("-" * 70) - -baseline_throughput = None - -for n_workers in WORKER_COUNTS: - success, fail = 0, 0 - t0 = time.time() - - with ThreadPoolExecutor(max_workers=n_workers) as pool: - futures = {pool.submit(download_single, url): url for url in urls} - for future in as_completed(futures): - try: - img = future.result(timeout=30) - success += 1 - except Exception: - fail += 1 - - elapsed = time.time() - t0 - fail_pct = (fail / N_SAMPLES) * 100 - throughput = success / elapsed if elapsed > 0 else 0 - - if baseline_throughput is None: - baseline_throughput = throughput - speedup = throughput / baseline_throughput if baseline_throughput > 0 else 0 - - label = f"{n_workers}" - if n_workers == safe_workers: - label += "*" # mark the safe_num_proc value - - print(f"{label:>8} | {elapsed:>7.1f}s | {success:>6} | {fail:>6} | {fail_pct:>5.1f}% | {throughput:>6.1f}/s | {speedup:>7.1f}x") - -print() -print("* = safe_num_proc value") -print("Done.") From c171573a8f1e4c243a26e6a99cf031afee8bc6aa Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Thu, 5 Mar 2026 06:10:10 +0000 Subject: [PATCH 33/33] fix: check for http(s) prefix instead of bare string type for URL detection --- studio/backend/utils/datasets/format_conversion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py index c5c9a4d6e7..41a9617857 100644 --- a/studio/backend/utils/datasets/format_conversion.py +++ b/studio/backend/utils/datasets/format_conversion.py @@ -350,7 +350,8 @@ def convert_to_vlm_format( return {"messages": messages} total = len(dataset) - has_urls = isinstance(next(iter(dataset))[image_column], str) + first_image = next(iter(dataset))[image_column] + has_urls = isinstance(first_image, str) and first_image.startswith(("http://", "https://")) # ── URL probe: 200 samples with parallel workers to estimate speed + failure rate ── PROBE_SIZE = 200