diff --git a/docker/.dockerignore b/docker/.dockerignore index 1bd005c9c1..df3d08f8c8 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -9,3 +9,4 @@ !unsloth_pip_shim.py !unsloth_ipython_startup.py !unsloth_run.py +!unsloth_sync_notebooks.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index fd3a8d1cdc..77c09061ad 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -642,22 +642,36 @@ RUN mkdir -p ${HF_HOME} ${TRITON_CACHE_DIR} # * unsloth-run: headless `unsloth-run ` that auto-picks the # sidecar and executes every cell -- the robust driven path. # --------------------------------------------------------------------------- -COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_ipython_startup.py unsloth_run.py /opt/unsloth-nb/ +COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_ipython_startup.py unsloth_run.py unsloth_sync_notebooks.sh /opt/unsloth-nb/ RUN set -eux \ && SP=/opt/unsloth-venv/lib/python${PYTHON_VERSION}/site-packages \ && cp /opt/unsloth-nb/unsloth_nb_compat.py "$SP/unsloth_nb_compat.py" \ - && chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py \ + && chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py /opt/unsloth-nb/unsloth_sync_notebooks.sh \ && mkdir -p /opt/unsloth-nb/bin \ && for t in pip pip3 uv; do ln -sf /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/bin/$t; done \ && ln -sf /opt/unsloth-nb/unsloth_run.py /usr/local/bin/unsloth-run \ + && ln -sf /opt/unsloth-nb/unsloth_sync_notebooks.sh /usr/local/bin/unsloth-sync-notebooks \ && mkdir -p /root/.ipython/profile_default/startup \ && cp /opt/unsloth-nb/unsloth_ipython_startup.py /root/.ipython/profile_default/startup/00-unsloth-nb.py \ && /opt/unsloth-venv/bin/python -c "import sys, glob; sys.path.insert(0, '$SP'); import unsloth_nb_compat; print('nb-compat OK; baked sidecars:', sorted(glob.glob('/opt/unsloth-venv/tf-sidecars/t_*')))" # Shim dir AHEAD of the venv bin so `!pip`/`!uv` resolve to the shim, not the real tool. ENV PATH=/opt/unsloth-nb/bin:${PATH} -# JupyterLab lives in the venv (see builder stage). Persistent notebooks -# should be bind-mounted onto /workspace. +# Pre-clone unslothai/notebooks so JupyterLab opens with the notebooks already +# present (no git clone or wget needed). Baked here as a READ-ONLY template +# (~206MB, .git stripped); on boot the entrypoint copies it to +# /workspace/unsloth-notebooks and best-effort refreshes from GitHub when +# upstream has advanced, never overwriting a notebook the user has edited (see +# unsloth_sync_notebooks.sh). Inherited as-is by the studio image (FROM base). +RUN set -eux \ + && git clone --depth 1 https://github.com/unslothai/notebooks /opt/unsloth-notebooks \ + && git -C /opt/unsloth-notebooks rev-parse HEAD > /opt/unsloth-notebooks/.unsloth_template_commit \ + && rm -rf /opt/unsloth-notebooks/.git \ + && du -sh /opt/unsloth-notebooks + +# JupyterLab lives in the venv (see builder stage). The unslothai/notebooks +# collection is pre-populated into /workspace/unsloth-notebooks on boot; mount a +# volume on /workspace to persist your own notebooks and outputs across runs. EXPOSE 8888 COPY smoke_test.py /workspace/smoke_test.py diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c365d86ad2..8195646ba3 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,7 +25,18 @@ if [[ -x /usr/local/cuda-13.0/bin/ptxas ]] && [[ -z "${TRITON_PTXAS_PATH:-}" ]]; export TRITON_PTXAS_PATH=/usr/local/cuda-13.0/bin/ptxas fi +# Make the unslothai/notebooks collection available under /workspace before the +# user command runs (JupyterLab, unsloth-run, or a shell). Best-effort: it is +# fully gated by UNSLOTH_SKIP_NOTEBOOK_SYNC and never blocks or fails the +# container (see unsloth_sync_notebooks.sh). +sync_notebooks() { + if [[ -x /usr/local/bin/unsloth-sync-notebooks ]]; then + /usr/local/bin/unsloth-sync-notebooks || true + fi +} + if [[ "${UNSLOTH_SKIP_GPU_CHECK:-0}" == "1" ]]; then + sync_notebooks exec "$@" fi @@ -44,6 +55,7 @@ if [[ "${UNSLOTH_ALLOW_CPU:-0}" == "1" ]]; then if ! command -v nvidia-smi >/dev/null 2>&1 || ! nvidia-smi -L 2>/dev/null | grep -q '^GPU'; then warn "UNSLOTH_ALLOW_CPU=1 and no GPU visible -- continuing on CPU." warn "Training requires an NVIDIA GPU. CPU mode covers Jupyter, GGUF tooling and Studio chat." + sync_notebooks exec "$@" fi fi @@ -146,4 +158,5 @@ if major < 8: print(" Unsloth will fall back to fp16. Training works but is slightly slower.") PY +sync_notebooks exec "$@" diff --git a/docker/unsloth_sync_notebooks.sh b/docker/unsloth_sync_notebooks.sh new file mode 100644 index 0000000000..7c50525ea3 --- /dev/null +++ b/docker/unsloth_sync_notebooks.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Populate and refresh /workspace/unsloth-notebooks from unslothai/notebooks. +# +# The image bakes a read-only template at /opt/unsloth-notebooks so the +# notebooks are present in JupyterLab instantly and offline. On boot this script +# copies the template into /workspace/unsloth-notebooks (first run only) and then +# best-effort refreshes from GitHub when upstream has actually advanced. +# +# The user's edits ALWAYS win. We remember the content hash of every file we +# wrote; on refresh a file whose current hash differs from what we last wrote is +# treated as user-modified and is left untouched. So a refresh only updates files +# the user has not changed and adds new ones -- it never clobbers an edited +# notebook and never produces merge conflicts. +# +# Opt-out / tuning (all optional): +# UNSLOTH_SKIP_NOTEBOOK_SYNC=1 do nothing (no populate, no refresh) +# UNSLOTH_SKIP_NOTEBOOK_REFRESH=1 populate from the baked template only; +# never touch the network +# UNSLOTH_NOTEBOOKS_DIR= target dir (default /workspace/unsloth-notebooks) +# UNSLOTH_NOTEBOOKS_REPO= source repo (default unslothai/notebooks) +# UNSLOTH_NOTEBOOK_FETCH_TIMEOUT=N seconds for each network op (default 60) +set -u + +TEMPLATE="${UNSLOTH_NOTEBOOKS_TEMPLATE:-/opt/unsloth-notebooks}" +DEST="${UNSLOTH_NOTEBOOKS_DIR:-/workspace/unsloth-notebooks}" +REMOTE="${UNSLOTH_NOTEBOOKS_REPO:-https://github.com/unslothai/notebooks}" +STATE="$DEST/.unsloth_sync_state" # "sha256 relpath" of what we last wrote +SYNCED="$DEST/.unsloth_sync_commit" # upstream commit we last synced to +TIMEOUT="${UNSLOTH_NOTEBOOK_FETCH_TIMEOUT:-60}" + +[ "${UNSLOTH_SKIP_NOTEBOOK_SYNC:-0}" = "1" ] && exit 0 +[ -d "$TEMPLATE" ] || exit 0 +mkdir -p "$DEST" 2>/dev/null || exit 0 + +hash_of() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1; } + +# Record " " for every file currently under DEST (skip metadata). +record_state() { + : > "$STATE.tmp" 2>/dev/null || return 0 + ( cd "$DEST" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do + rel="${rel#./}" + case "$rel" in + .unsloth_sync_state|.unsloth_sync_commit) continue ;; + esac + printf '%s %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp" + done + mv "$STATE.tmp" "$STATE" 2>/dev/null || rm -f "$STATE.tmp" +} + +# 1) First-boot populate from the baked template (instant, works offline). +if [ ! -f "$STATE" ]; then + ( cd "$TEMPLATE" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do + rel="${rel#./}" + case "$rel" in .unsloth_template_commit) continue ;; esac + mkdir -p "$DEST/$(dirname "$rel")" 2>/dev/null || true + cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null || true + done + record_state + cp -a "$TEMPLATE/.unsloth_template_commit" "$SYNCED" 2>/dev/null || true + echo "[unsloth-nb] notebooks ready at $DEST" +fi + +# 2) Best-effort GitHub refresh -- only when upstream has advanced. Edits win. +[ "${UNSLOTH_SKIP_NOTEBOOK_REFRESH:-0}" = "1" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 +command -v sha256sum >/dev/null 2>&1 || exit 0 + +last="$(cat "$SYNCED" 2>/dev/null || true)" +remote="$(timeout "$TIMEOUT" git ls-remote "$REMOTE" HEAD 2>/dev/null | cut -f1)" +[ -z "$remote" ] && exit 0 # offline / unreachable -> keep what we have +[ "$remote" = "$last" ] && exit 0 # nothing new since last sync -> done + +TMP="$(mktemp -d)" +if ! timeout "$TIMEOUT" git clone -q --depth 1 "$REMOTE" "$TMP" 2>/dev/null; then + rm -rf "$TMP"; exit 0 # network died mid-way -> keep what we have +fi + +declare -A LAST +if [ -f "$STATE" ]; then + while read -r h p; do + [ -n "${p:-}" ] && LAST["$p"]="$h" + done < "$STATE" +fi + +TMPSTATE="$(mktemp)" +updated=0; kept=0 +while IFS= read -r -d '' f; do + rel="${f#"$TMP"/}" + case "$rel" in .git|.git/*) continue ;; esac + dst="$DEST/$rel" + if [ -e "$dst" ]; then + rec="${LAST[$rel]:-}" + if [ -n "$rec" ] && [ "$(hash_of "$dst")" != "$rec" ]; then + # User changed this file since we wrote it -> keep theirs, keep marker. + printf '%s %s\n' "$rec" "$rel" >> "$TMPSTATE" + kept=$((kept + 1)) + continue + fi + fi + mkdir -p "$(dirname "$dst")" 2>/dev/null || true + if cp -a "$f" "$dst" 2>/dev/null; then + printf '%s %s\n' "$(hash_of "$dst")" "$rel" >> "$TMPSTATE" + updated=$((updated + 1)) + fi +done < <(find "$TMP" -type f -print0) + +mv "$TMPSTATE" "$STATE" 2>/dev/null || rm -f "$TMPSTATE" +echo "$remote" > "$SYNCED" 2>/dev/null || true +rm -rf "$TMP" +echo "[unsloth-nb] notebooks refreshed from GitHub: $updated updated, $kept kept (your edits)" +exit 0