# Apple MLX: ограничение нормы обнуляет конечные градиенты

Локальный технический отчёт, 10 сентября 2026 года. Дефект воспроизведён
на настоящем MLX 0.32.2, CPU. Подготовлен и проверен изолированный
исследовательский патч `mlx.optimizers.clip_grad_norm`.
Ничего не публиковалось, не отправлялось и не применялось к рабочей копии MLX.

## Проверенный контрпример

```python
import mlx.core as mx
import mlx.optimizers as optim
mx.set_default_device(mx.cpu)
g = {"w": mx.array([3072., 4096.], dtype=mx.float16)}
clipped, norm = optim.clip_grad_norm(g, 1.0)
print(norm.item(), clipped["w"].tolist())
# MLX 0.32.2: inf, [0.0, 0.0]
# Правильно: 5120, приблизительно [0.6, 0.8]
```

Входы и настоящая норма 5120 представимы в float16. Переполняется
промежуточный квадрат. Затем коэффициент ограничения становится нулём.
Проверен реальный шаг `SGD.apply_gradients` от нулевых параметров:
при learning_rate=0.125 исходный код возвращает `[0,0]`, исправленный —
примерно `[-0.075,-0.1]`. Коэффициент 0.125 выбран точно представимым
в двоичной арифметике.

| dtype | Вектор `[3C,4C]`, C | Правильная норма | Исходный clipped | После патча |
|---|---:|---:|---|---|
| float16 | 2¹⁰ | 5120 | `[0,0]` | ≈`[0.6,0.8]` |
| bfloat16 | 2⁸⁰ | 6.0446291·10²⁴ | `[0,0]` | ≈`[0.6,0.8]` |
| float32 | 2⁸⁰ | 6.0446291·10²⁴ | `[0,0]` | ≈`[0.6,0.8]` |
| float64 | 2⁶⁰⁰ | 2.0747578·10¹⁸¹ | `[0,0]` | ≈`[0.6,0.8]` |

Даже если каждый отдельный квадрат представим, переполниться может
сумма квадратов. Отдельные случаи для всех четырёх dtype проверяют это
на векторах длины 32. Например, float16 сообщает `inf` вместо нормы,
округляемой до 723.5. На малых масштабах наблюдается обратная проблема:
квадраты исчезают, и возвращаемая норма ошибочно становится нулевой.

Сохранены [пробник](probe.py), [измерения установленного wheel](wheel-reproduction.json)
и [исходный текст установленной функции](installed-clip-source.py).
Первый пробник использовал learning_rate=0.1; основной тест использует 0.125,
чтобы не смешивать этот дефект с обычным представлением learning rate.

## Версия и причина

На момент чтения публичный `main` был
`81ba1c6a0e50a9268b931579c2d4f1158b9aab5a`.
В [исходной функции](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/python/mlx/optimizers/optimizers.py#L963)
норма вычисляется непосредственно из суммы `g.square().sum()`.
Этот же текст получен из установленного wheel.
[Источники, время чтения и SHA256](source-metadata.json) сохранены локально.

Тест до исправления исполняет точную функцию, выделенную AST из сохранённого
публичного Python-файла, на установленном бинарном MLX 0.32.2.
Это реальный runtime, но не полная сборка текущего main. Проверка после
исполняет [candidate.py](candidate.py) на том же runtime.
Тело [патча](clip-grad-norm-range.patch) автоматически сверено с проверенным
прототипом, кроме сохранённой публичной docstring.

## Изменение и его границы

Для всех вещественных листьев дерева сначала находится общий максимум
`m=max(abs(g))`. При m>0 норма вычисляется как

    r = sqrt(sum((g/m)²))
    N = m*r.

Суммирование выполняется как минимум в float32; double сохраняется.
Выходные dtype, структура дерева, отрицательный-порог ValueError и
используемая исходным кодом добавка ε=10⁻⁶ сохранены в проверенных случаях.
При истинной норме вне диапазона выходного dtype возвращаемая норма может
законно быть `inf`; вычисление clipped всё равно остаётся конечным.

Одного устойчивого расчёта N недостаточно: слишком малый коэффициент T/N
может исчезнуть до умножения на большой градиент. Постоянный порядок
`(g/m)*(T/r)` также может потерять малую координату до умножения на большой T.
В активной ветви при m≥1 используется D=r+ε/m и два числителя g,T:
больший по модулю L делится на m, второй S — на D. Затем перемножаются
`(L/m)*(S/D)`. В точной арифметике это тот же `g*T/(N+ε)`.
При m<1 используется обычный коэффициент после устойчивого расчёта N.

Проверены, среди прочего, float32-вектор `[2⁻¹²⁰,−2³⁰]` с порогом 2²⁹
и `[2⁹⁰,−2¹⁰⁰]` с порогом 2⁻¹⁰⁰. Малые ожидаемые выходы в этих случаях
остаются нормальными, поэтому их исчезновение нельзя списать на
непредставимость результата.

Для нулевого вектора корень вычисляется через безопасную ветвь;
нормирующий масштаб остановлен для autodiff. Проверены производные
самого clipped, включая нулевую точку. Дифференцируемость самой нормы
в нуле не утверждается. Комплексный путь оставлен исходным; корректность
комплексного clipping здесь не заявляется.

Это исследовательский вариант: дополнительный проход максимума, приведение
типов и покоординатный выбор порядка имеют стоимость. Производительность
на больших моделях не измерена. Патч не объявляется готовым к принятию
без ревью сопровождающих. Полнота по всем субнормальным значениям и всем
возможным деревьям не доказана.

## Проверки

[Основной тест](regression.py) использует Decimal с точностью 100 цифр
для независимого эталона нормы и clipping по уже квантованным входам.
Выход эталона округляется до проверяемого dtype. Сравнение относительное,
без большого абсолютного допуска, скрывающего исчезновение малых чисел.

| Набор | Объём | До | После |
|---|---|---:|---:|
| Основной: 219 входных сценариев и SGD | 1701 assertion | 230 ошибок | 0 ошибок |
| Совместимость, включая точный штатный тест | 31 общая проверка | 17 ошибок | 0 ошибок |
| Дополнительное сравнение NaN/Inf и complex с исходным поведением | 8 проверок после | — | 0 ошибок |

1701 — число проверок, а не различных векторов. Из них 633 сравнивают
численные результаты нормы, листьев clipped или SGD; остальные проверяют
dtype, структуру дерева, неизменность входов и исключение.
230 ошибок до состоят из 108 неверных норм, 118 неверных листьев clipped
и четырёх неверных шагов SGD.

Основной набор охватывает четыре dtype, разные масштабы и пороги,
порог 0, малые/большие и нулевые градиенты, пустые массивы/деревья,
вложенные списки/кортежи/словари, смешанные dtype, целочисленный пример
из документации и несбалансированные координаты.

[Проверки совместимости](compatibility.py) исполняют неизменённый публичный
метод `test_clip_grad_norm` с настоящими unittest-assertions.
Для float32/float64 проверены JVP, VJP и вторая производная композиции
с вектором C·[3t,4] в восьми сочетаниях dtype/масштаба, а также неактивное
ограничение и нулевая точка. Четыре ошибки исходного JVP в нуле относятся
к взаимодействию с корнем в нуле; они не считаются отдельной новой находкой.

Результаты: [основной набор](regression-results.json),
[совместимость](compatibility-results.json).
CPU-время финальных двух численных прогонов: около 0.096 и 0.029 секунды.
Это не benchmark производительности функций.

Все вычисления выполнялись последовательно на CPU; переменные численных
потоков установлены в 1. GPU, распределённые процессы, обучение модели
и полная сборка MLX не запускались. Установленному MLX потребовался доступ
за пределами песочницы для инициализации; вычислительное устройство
явно установлено в `mx.cpu` перед тестами.

## Проверка дубликатов

Четыре GitHub API-поиска после повторной проверки вернули девять уникальных
issues/PR. Прочитаны их тексты и 13 обычных комментариев у четырёх обсуждений. Сохранены
[результаты поиска](duplicate-search.json) и [комментарии](duplicate-comments.json).

- [#4058](https://github.com/ml-explore/mlx/pull/4058) касается отрицательного
  max_norm. Эта проверка уже присутствует; здесь пороги неотрицательны.
- [#1040](https://github.com/ml-explore/mlx/issues/1040) и
  [#1043](https://github.com/ml-explore/mlx/pull/1043) — добавление clipping.
- [#3090](https://github.com/ml-explore/mlx/pull/3090) — слияние операций
  с reduction для производительности.
- [#4230](https://github.com/ml-explore/mlx/pull/4230) — известная проблема
  reduced-precision InstanceNorm, другой путь.
- #1622, #1623 и #2837 касаются применения оптимизаторов и их интерфейса.
- [#1049](https://github.com/ml-explore/mlx/issues/1049) касается осей
  LayerNorm, согласования epsilon с Keras и индексного переполнения
  больших матриц, а не переполнения нормы при clipping конечного градиента.

Первый ответ поиска `norm overflow` сервер пометил `incomplete_results=true`.
Проверка артефактов это обнаружила. Повторный запрос вернул полный ответ
с дополнительным #1049; первый и повторный ответы сохранены в
[duplicate-search-first.json](duplicate-search-first.json) и
[duplicate-search-retry.json](duplicate-search-retry.json).

Точного дубликата в этих материалах не найдено. Полнота GitHub-индекса,
все review-комментарии, внешние трекеры и частные отчёты не проверены;
абсолютная новизна не утверждается.

## Соседние наблюдения и ограничения вывода

Пробник также воспроизводит переполнение `mx.linalg.norm` на тех же
векторах. Его [l2_norm](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/mlx/linalg.cpp#L56)
использует собственную сумму квадратов. `clip_grad_norm` не вызывает эту
функцию, поэтому данный патч не исправляет standalone linalg.norm.
Это соседнее наблюдение, без отдельного готового патча в этом пакете.

В [clip_grad_norm_sharded](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/python/mlx/nn/utils.py#L176)
видна аналогичная локальная сумма квадратов перед распределённым all_sum.
Это только наблюдение по исходникам: распределённый путь не запускался
и отдельным воспроизведённым дефектом здесь не считается.

Подтверждён неверный результат clipping и один синтетический шаг SGD.
Потери качества целой модели, эксплуатация безопасности, право на bounty
или готовность Apple заплатить не установлены.

## История проверки и воспроизведение

Первый прототип прошёл основной набор, но изменил распространение NaN
в двух случаях. Исправлен предикат выбора ветви. Сохранены
[candidate-v1.py](candidate-v1.py), [его основной результат](regression-v1-results.json)
и [два несовпадения совместимости](compatibility-v1-results.json).
Первый запуск стенда совместимости завершился StopIteration: штатный тест
находится в классе TestSchedulers, а не TestOptimizers. Исправлено извлечение
метода, это не ошибка MLX. [Заметки стенда](harness-notes.txt).

```sh
/Users/khamit/Documents/math/software-audits/current-stack-2026-09-07/.venv/bin/python probe.py
/Users/khamit/Documents/math/software-audits/current-stack-2026-09-07/.venv/bin/python regression.py
/Users/khamit/Documents/math/software-audits/current-stack-2026-09-07/.venv/bin/python compatibility.py
python3 build_patch.py
python3 validate_artifacts.py
```

Каждую команду запускают из этой папки, последовательно. Численные скрипты
сами задают один поток и CPU-лимит 30 секунд. Для повторения тестов сеть
не нужна: публичные исходники уже сохранены. `fetch_sources.py` и
`fetch_comments.py` нужны только для нового чтения публичных данных.

[Проверка целостности артефактов](artifact-validation.json) включает
применение патча в отдельной временной папке, сравнение с испытанным телом
функции и пересчёт итогов. [Контрольные суммы файлов](SHA256SUMS.json).
