{
  "cells": [
    {
      "cell_type": "markdown",
      "id": "introduction",
      "metadata": {
        "tags": []
      },
      "source": [
        "# Mesurer la mémoire d'un petit modèle synthétique\n",
        "\n",
        "IteraGPU Lab v1 — 24 septembre 2026 — MIT. Notebook original et autonome, sans téléchargement de modèle ni dépendance au script compagnon. Toutes les sorties distribuées sont vides.\n",
        "\n",
        "Nous séparons un calcul de stockage et une mesure de l'allocateur PyTorch sur un seul GPU. Le modèle est un MLP `Linear → GELU → Linear`, sans attention, cache KV, entraînement ou métrique métier. `context` désigne uniquement la deuxième dimension de l'entrée. Ce notebook ne classe pas les GPU du catalogue.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "prerequisites",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Prérequis\n",
        "\n",
        "Python 3.10+, un lecteur de notebooks Python ; PyTorch déjà installé et compatible avec le GPU/pilote seulement pour la mesure. Aucune cellule n'installe de paquet ni n'accède au réseau. Le diagnostic peut signaler une indisponibilité. La cellule GPU lèvera une erreur explicite si PyTorch ou le device choisi manque ; aucun repli CPU ne fabrique une mesure.\n",
        "\n",
        "Commencez dans un noyau neuf. Le script compagnon permet aussi un processus neuf pour chaque essai. Les fonctions sont incluses ci-dessous pour permettre l'utilisation du notebook seul.\n"
      ]
    },
    {
      "cell_type": "code",
      "id": "standalone-functions",
      "metadata": {
        "tags": [
          "cpu-only",
          "definitions"
        ]
      },
      "source": [
        "#!/usr/bin/env python3\n",
        "\"\"\"IteraGPU Lab v1 — arithmétique CPU et mesure d'un MLP synthétique sur UN GPU.\n",
        "\n",
        "Aucun téléchargement ni installation. Python >= 3.10. Licence MIT.\n",
        "Les fonctions précédant main() sont aussi intégrées au notebook autonome.\n",
        "\"\"\"\n",
        "import argparse\n",
        "import gc\n",
        "import json\n",
        "import platform\n",
        "import re\n",
        "import sys\n",
        "import time\n",
        "from datetime import datetime, timezone\n",
        "from decimal import Decimal, InvalidOperation, localcontext\n",
        "from pathlib import Path\n",
        "\n",
        "\n",
        "class MeasurementUnavailable(RuntimeError):\n",
        "    \"\"\"Prérequis manquant ou mesure impossible, jamais remplacé par un résultat CPU.\"\"\"\n",
        "\n",
        "\n",
        "def positive_integer(value, name):\n",
        "    if isinstance(value, bool) or not isinstance(value, int) or value <= 0:\n",
        "        raise ValueError(f\"{name} doit être un entier strictement positif.\")\n",
        "    return value\n",
        "\n",
        "\n",
        "def estimate_weights(parameters, bits, reserve_gib=\"0\"):\n",
        "    \"\"\"Poids théoriques seulement ; la réserve est une hypothèse fournie par l'utilisateur.\"\"\"\n",
        "    positive_integer(parameters, \"parameters\")\n",
        "    if isinstance(bits, bool) or bits not in (4, 8, 16, 32):\n",
        "        raise ValueError(\"bits doit valoir 4, 8, 16 ou 32.\")\n",
        "    try:\n",
        "        reserve = Decimal(str(reserve_gib))\n",
        "    except InvalidOperation as error:\n",
        "        raise ValueError(\"reserve_gib doit être un nombre décimal fini positif ou nul.\") from error\n",
        "    if not reserve.is_finite() or reserve < 0:\n",
        "        raise ValueError(\"reserve_gib doit être un nombre décimal fini positif ou nul.\")\n",
        "    weight_bytes = (parameters * bits + 7) // 8\n",
        "    with localcontext() as ctx:\n",
        "        ctx.prec = max(40, len(str(parameters)) + 20)\n",
        "        weight_gib = Decimal(weight_bytes) / Decimal(2**30)\n",
        "        total = weight_gib + reserve\n",
        "        return {\n",
        "            \"kind\": \"arithmetic_only\",\n",
        "            \"parameters\": parameters,\n",
        "            \"bits_per_parameter\": bits,\n",
        "            \"theoretical_weight_bytes\": weight_bytes,\n",
        "            \"theoretical_weight_gib\": str(weight_gib),\n",
        "            \"assumed_reserve_gib\": str(reserve),\n",
        "            \"weights_plus_assumed_reserve_gib\": str(total),\n",
        "            \"scope\": \"Poids stockés, arrondis à l'octet supérieur ; pas de pic mesuré. \"\n",
        "                     \"Métadonnées de quantification, activations, cache, gradients et optimiseur exclus. \"\n",
        "                     \"La réserve ajoutée est une hypothèse, pas une garantie de capacité.\",\n",
        "        }\n",
        "\n",
        "\n",
        "def parse_device(device):\n",
        "    if not isinstance(device, str) or not re.fullmatch(r\"cuda:[0-9]+\", device):\n",
        "        raise ValueError(\"Choisir un seul GPU explicitement : cuda:0, cuda:1, etc. CPU non mesuré.\")\n",
        "    return int(device.split(\":\")[1])\n",
        "\n",
        "\n",
        "def load_torch():\n",
        "    try:\n",
        "        import torch\n",
        "    except (ImportError, OSError) as error:\n",
        "        raise MeasurementUnavailable(\n",
        "            \"PyTorch est absent ou ne peut pas être chargé. Aucune mesure GPU effectuée. \"\n",
        "            \"Utilisez estimate sans PyTorch ; préparez séparément un environnement compatible \"\n",
        "            \"avec votre GPU et votre pilote pour measure. Ce script n'installe rien.\"\n",
        "        ) from error\n",
        "    return torch\n",
        "\n",
        "\n",
        "def environment_report(device=\"cuda:0\"):\n",
        "    index = parse_device(device)\n",
        "    report = {\n",
        "        \"python_version\": platform.python_version(),\n",
        "        \"platform\": platform.system(),\n",
        "        \"requested_device\": device,\n",
        "        \"torch_importable\": False,\n",
        "        \"torch_version\": None,\n",
        "        \"cuda_build_version\": None,\n",
        "        \"hip_build_version\": None,\n",
        "        \"gpu_available\": False,\n",
        "        \"visible_device_count\": 0,\n",
        "        \"selected_device_available\": False,\n",
        "        \"selected_device_name\": None,\n",
        "        \"selected_device_total_bytes\": None,\n",
        "        \"driver_version\": None,\n",
        "        \"driver_note\": \"Relever séparément la version du pilote avec l'outil du fournisseur.\",\n",
        "    }\n",
        "    try:\n",
        "        torch = load_torch()\n",
        "        report.update(torch_importable=True, torch_version=str(torch.__version__),\n",
        "                      cuda_build_version=torch.version.cuda, hip_build_version=torch.version.hip)\n",
        "        report[\"gpu_available\"] = bool(torch.cuda.is_available())\n",
        "        report[\"visible_device_count\"] = torch.cuda.device_count() if report[\"gpu_available\"] else 0\n",
        "        if report[\"gpu_available\"] and index < report[\"visible_device_count\"]:\n",
        "            properties = torch.cuda.get_device_properties(index)\n",
        "            report.update(selected_device_available=True, selected_device_name=properties.name,\n",
        "                          selected_device_total_bytes=properties.total_memory)\n",
        "    except MeasurementUnavailable as error:\n",
        "        report[\"reason\"] = str(error)\n",
        "    except RuntimeError:\n",
        "        report[\"reason\"] = \"Le runtime GPU ne répond pas correctement. Aucune mesure effectuée.\"\n",
        "    return report\n",
        "\n",
        "\n",
        "def record_phase(torch, device, phase, run_index, operation):\n",
        "    \"\"\"Pics absolus depuis reset, avec baseline ; le résultat reste vivant à la lecture.\"\"\"\n",
        "    torch.cuda.synchronize(device)\n",
        "    baseline_allocated = torch.cuda.memory_allocated(device)\n",
        "    baseline_reserved = torch.cuda.memory_reserved(device)\n",
        "    torch.cuda.reset_peak_memory_stats(device)\n",
        "    start = time.perf_counter()\n",
        "    result = operation()\n",
        "    torch.cuda.synchronize(device)\n",
        "    elapsed = time.perf_counter() - start\n",
        "    row = {\n",
        "        \"phase\": phase,\n",
        "        \"run_index\": run_index,\n",
        "        \"elapsed_seconds\": elapsed,\n",
        "        \"baseline_allocated_bytes\": baseline_allocated,\n",
        "        \"baseline_reserved_bytes\": baseline_reserved,\n",
        "        \"end_allocated_bytes\": torch.cuda.memory_allocated(device),\n",
        "        \"end_reserved_bytes\": torch.cuda.memory_reserved(device),\n",
        "        \"peak_allocated_bytes\": torch.cuda.max_memory_allocated(device),\n",
        "        \"peak_reserved_bytes\": torch.cuda.max_memory_reserved(device),\n",
        "    }\n",
        "    return result, row\n",
        "\n",
        "\n",
        "def measure(device=\"cuda:0\", batch=2, context=128, width=1024, dtype=\"float32\", warmup=3,\n",
        "            repeats=5, seed=1729):\n",
        "    index = parse_device(device)\n",
        "    for name, value in ((\"batch\", batch), (\"context\", context), (\"width\", width),\n",
        "                        (\"warmup\", warmup), (\"repeats\", repeats)):\n",
        "        positive_integer(value, name)\n",
        "    if dtype not in (\"float32\", \"float16\", \"bfloat16\"):\n",
        "        raise ValueError(\"dtype doit valoir float32, float16 ou bfloat16.\")\n",
        "    if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed < 2**63:\n",
        "        raise ValueError(\"seed doit être un entier compris entre 0 et 2**63 - 1.\")\n",
        "    torch = load_torch()\n",
        "    env = environment_report(device)\n",
        "    if not env[\"selected_device_available\"]:\n",
        "        raise MeasurementUnavailable(\n",
        "            f\"GPU {device} indisponible : vérifier GPU visible, build PyTorch et pilote. \"\n",
        "            \"Aucune mesure effectuée ; aucun repli vers le CPU.\"\n",
        "        )\n",
        "    selected = torch.device(device)\n",
        "    rows = []\n",
        "    with torch.cuda.device(index):\n",
        "        if dtype == \"bfloat16\" and not torch.cuda.is_bf16_supported():\n",
        "            raise MeasurementUnavailable(\"bfloat16 non déclaré pris en charge sur le GPU choisi.\")\n",
        "        scalar_type = getattr(torch, dtype)\n",
        "        cpu_generator = torch.Generator(device=\"cpu\").manual_seed(seed)\n",
        "        gpu_generator = torch.Generator(device=selected).manual_seed(seed)\n",
        "        previous_precision = torch.get_float32_matmul_precision()\n",
        "        torch.set_float32_matmul_precision(\"highest\")\n",
        "        try:\n",
        "            def make_model():\n",
        "                # Initialisation CPU incluse dans model_load ; poids ensuite transférés au GPU choisi.\n",
        "                model = torch.nn.Sequential(torch.nn.Linear(width, width), torch.nn.GELU(),\n",
        "                                            torch.nn.Linear(width, width))\n",
        "                with torch.no_grad():\n",
        "                    for parameter in model.parameters():\n",
        "                        if parameter.ndim > 1:\n",
        "                            parameter.normal_(0, 0.02, generator=cpu_generator)\n",
        "                        else:\n",
        "                            parameter.zero_()\n",
        "                return model.to(device=selected, dtype=scalar_type).eval()\n",
        "\n",
        "            model, row = record_phase(torch, selected, \"model_load\", 0, make_model)\n",
        "            rows.append(row)\n",
        "            inputs, row = record_phase(\n",
        "                torch, selected, \"inputs\", 0,\n",
        "                lambda: torch.randn(batch, context, width, device=selected,\n",
        "                                    dtype=scalar_type, generator=gpu_generator))\n",
        "            rows.append(row)\n",
        "            with torch.inference_mode():\n",
        "                output, row = record_phase(torch, selected, \"cold_forward\", 0, lambda: model(inputs))\n",
        "                rows.append(row)\n",
        "                del output\n",
        "                gc.collect()\n",
        "                torch.cuda.synchronize(selected)\n",
        "\n",
        "                def warmup_passes():\n",
        "                    for _ in range(warmup):\n",
        "                        temporary_output = model(inputs)\n",
        "                        del temporary_output\n",
        "\n",
        "                _, row = record_phase(torch, selected, \"warmup\", 0, warmup_passes)\n",
        "                row[\"iterations\"] = warmup\n",
        "                rows.append(row)\n",
        "                for repetition in range(1, repeats + 1):\n",
        "                    output, row = record_phase(torch, selected, \"warm_forward\", repetition,\n",
        "                                               lambda: model(inputs))\n",
        "                    rows.append(row)\n",
        "                    del output\n",
        "                    gc.collect()\n",
        "                    torch.cuda.synchronize(selected)\n",
        "            parameter_count = sum(parameter.numel() for parameter in model.parameters())\n",
        "            parameter_bytes = sum(parameter.numel() * parameter.element_size()\n",
        "                                  for parameter in model.parameters())\n",
        "            del inputs, model\n",
        "            gc.collect()\n",
        "            torch.cuda.synchronize(selected)\n",
        "        finally:\n",
        "            torch.set_float32_matmul_precision(previous_precision)\n",
        "    return {\n",
        "        \"schema_version\": 1,\n",
        "        \"artifact\": \"iteragpu-lab-v1\",\n",
        "        \"measured_at_utc\": datetime.now(timezone.utc).isoformat(),\n",
        "        \"environment\": env,\n",
        "        \"configuration\": {\"device\": device, \"batch\": batch, \"context\": context, \"width\": width,\n",
        "                          \"dtype\": dtype, \"warmup\": warmup, \"repeats\": repeats, \"seed\": seed,\n",
        "                          \"float32_matmul_precision\": \"highest\", \"autocast\": False,\n",
        "                          \"training\": False},\n",
        "        \"synthetic_model\": {\"definition\": \"Linear(width,width), GELU, Linear(width,width)\",\n",
        "                            \"parameter_count\": parameter_count, \"parameter_bytes\": parameter_bytes},\n",
        "        \"scope\": \"Allocateur PyTorch de ce processus sur le seul device choisi. Ni mémoire totale \"\n",
        "                 \"de la machine, ni autre processus, ni allocation externe à PyTorch, ni CPU. \"\n",
        "                 \"cold_forward est le premier passage après initialisation du device et du modèle. \"\n",
        "                 \"Le MLP synthétique ne mesure ni LLM, ni entraînement, ni qualité métier.\",\n",
        "        \"interpretation\": \"allocated fait partie de reserved : ne pas les additionner. Les maxima \"\n",
        "                          \"peuvent venir d'instants différents : ne pas soustraire les deux pics. \"\n",
        "                          \"Les baselines et pics sont absolus ; le cache de l'allocateur est conservé.\",\n",
        "        \"phases\": rows,\n",
        "    }\n",
        "\n",
        "\n"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "arithmetic-notes",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Calcul indépendant du GPU\n",
        "\n",
        "L'exemple utilise 7 milliards de paramètres sur 16 bits et une réserve **supposée** de 4 Gio. C'est une hypothèse arithmétique, jamais un pic mesuré. Les poids sont arrondis à l'octet supérieur ; 1 Gio = 2**30 octets, alors que 1 Go = 10**9 octets. Quantification, activations, cache, gradients et optimiseur nécessitent des informations supplémentaires.\n"
      ]
    },
    {
      "cell_type": "code",
      "id": "arithmetic",
      "metadata": {
        "tags": [
          "cpu-only"
        ]
      },
      "source": [
        "estimate = estimate_weights(parameters=7_000_000_000, bits=16, reserve_gib=\"4\")\n",
        "print(json.dumps(estimate, ensure_ascii=False, indent=2))\n"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "environment-notes",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Vérifier le device choisi\n",
        "\n",
        "`cuda:0` désigne ici un seul GPU visible. PyTorch sur ROCm utilise également cette interface ; l'environnement réel reste à vérifier. La version CUDA/HIP retournée est celle du build PyTorch. Relevez la version du pilote séparément avec l'outil du fournisseur et conservez-la avec votre essai.\n"
      ]
    },
    {
      "cell_type": "code",
      "id": "environment",
      "metadata": {
        "tags": [
          "environment-check"
        ]
      },
      "source": [
        "DEVICE = \"cuda:0\"\n",
        "environment = environment_report(DEVICE)\n",
        "print(json.dumps(environment, ensure_ascii=False, indent=2))\n"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "measurement-notes",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Exécuter d'abord un très petit cas\n",
        "\n",
        "Batch 1, contexte 16, largeur 64, float32 ; un échauffement et deux répétitions. Ce choix limite la taille de l'essai sans garantir sa compatibilité avec toute machine. Les poids et entrées ont le même dtype, sans autocast. Modifier une dimension à la fois pour étudier son effet ; les dtypes float16/bfloat16 nécessitent leur propre validation.\n",
        "\n",
        "Les phases sont `model_load` (initialisation CPU et transfert), `inputs`, `cold_forward`, `warmup`, puis une ligne `warm_forward` par répétition. « Cold » signifie premier passage du modèle après initialisation du device et des données, pas démarrage à froid du pilote ou de la machine. Le chronomètre inclut Python et la synchronisation, pas seulement le calcul du noyau GPU.\n"
      ]
    },
    {
      "cell_type": "code",
      "id": "measurement",
      "metadata": {
        "tags": [
          "requires-gpu"
        ]
      },
      "source": [
        "measurement = measure(device=DEVICE, batch=1, context=16, width=64, dtype=\"float32\", warmup=1, repeats=2)\n",
        "print(json.dumps(measurement, ensure_ascii=False, indent=2))\n"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "interpretation",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Lire les résultats sans double comptage\n",
        "\n",
        "Avant chaque phase : synchronisation, baseline allouée/réservée, remise à zéro des pics ; après l'opération : synchronisation et relevés. Les sorties existent encore au relevé puis sont libérées. Le modèle, les entrées et le cache de l'allocateur restent présents entre passages.\n",
        "\n",
        "`allocated` est une partie de `reserved` : ne pas les additionner. Les maxima alloué et réservé peuvent venir d'instants différents : ne pas les soustraire pour déduire un cache. Garder séparément les baselines, fins et pics absolus. Le périmètre est l'allocateur PyTorch de ce processus sur ce seul device, pas la mémoire du pilote, des autres processus, du CPU ou d'un second GPU.\n",
        "\n",
        "Relancer un noyau/processus neuf pour comparer des premiers passages. Conserver les observations brutes et les versions. Des répétitions d'un même cas ne constituent pas une preuve de capacité d'un LLM, ni une comparaison de matériels.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "export-notes",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Conserver volontairement son essai\n",
        "\n",
        "La cellule suivante crée un **nouveau** fichier et refuse l'écrasement. Elle n'est utile qu'après une mesure réussie. Le notebook distribué ne contient pas de résultat GPU préenregistré.\n"
      ]
    },
    {
      "cell_type": "code",
      "id": "export",
      "metadata": {
        "tags": [
          "requires-gpu",
          "optional-export"
        ]
      },
      "source": [
        "output_path = Path(\"mesures-notebook.json\")\n",
        "with output_path.open(\"x\", encoding=\"utf-8\") as handle:\n",
        "    handle.write(json.dumps(measurement, ensure_ascii=False, indent=2) + \"\\n\")\n"
      ],
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "sources-and-validation",
      "metadata": {
        "tags": []
      },
      "source": [
        "## Qualité, coût et validation\n",
        "\n",
        "Pour une tâche réelle, fixer d'abord les données, la règle de qualité et l'unité utile avec `protocole-qualite.md`. Le MLP de ce notebook ne valide aucune qualité métier. `resultats-bruts.csv` est vierge ; `calcul_forfaits.py` calcule séparément le forfait complet selon les tarifs fournis.\n",
        "\n",
        "Validation de cette version : arithmétique et garde d'absence PyTorch sous Python 3.12.14 ; petit essai fonctionnel du script sur RTX 5070 locale, pilote 610.62, Python 3.14.6, PyTorch 2.11.0+cu128, CUDA 12.8, B1/T16/W64/float32, un warmup et deux répétitions. Ce contrôle n'évalue aucun GPU loué ou du catalogue. Le notebook est livré sans sorties ; il n'a pas été exécuté par un moteur Jupyter dans cet environnement.\n",
        "\n",
        "Sources primaires : [mémoire PyTorch](https://docs.pytorch.org/docs/2.14/notes/cuda.html#memory-management), [synchronisation](https://docs.pytorch.org/docs/2.14/generated/torch.cuda.synchronize.html), [reset des pics](https://docs.pytorch.org/docs/2.14/generated/torch.cuda.memory.reset_peak_memory_stats.html), [HIP](https://docs.pytorch.org/docs/2.14/notes/hip.html), [Decimal Python](https://docs.python.org/3/library/decimal.html). API documentée 2.14, runtime testé 2.11. Licence MIT : conserver la notice accompagnant ce dossier.\n"
      ]
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python",
      "version": "3.10"
    },
    "license": "MIT"
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
