# Apple MLX: масштаб меняет производные обычного деления

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

## Точный контрпример

Для положительного C функция

    f(t) = C / (C·t) = 1/t

не зависит от C. При t=1 её первые три производные равны −1, 2, −6.
Прямой результат настоящего MLX равен 1 во всех следующих случаях:

| dtype | C | f′(1) MLX | f″(1) MLX | f‴(1) MLX | Центральная разность forward |
|---|---:|---:|---:|---:|---:|
| float32 | 1 | −1 | 2 | −6 | −1.00024604797 |
| float32 | 2⁻⁸⁰ | **−inf** | NaN | NaN | −1.00024604797 |
| float32 | 2⁸⁰ | **−0** | NaN | NaN | −1.00024604797 |
| float64 | 1 | −1 | 2 | −6 | −1.00000095368 |
| float64 | 2⁻⁶⁰⁰ | **−inf** | NaN | NaN | −1.00000095368 |
| float64 | 2⁶⁰⁰ | **−0** | NaN | NaN | −1.00000095368 |

Шаг центральной разности — 2⁻⁶ для float32 и 2⁻¹⁰ для float64.
Она вычислена по реальным прямым значениям MLX и независимо
подтверждает первый градиент. Второй и третий порядки проверяются
аналитическими формулами для 1/t.

Минимальное воспроизведение:

    import mlx.core as mx
    mx.set_default_device(mx.cpu)
    t = mx.array(1., dtype=mx.float32)
    for power in (0, -80, 80):
        c = mx.array(2.**power, dtype=mx.float32)
        f = lambda z: c / (c*z)
        print(power, f(t).item(), mx.grad(f)(t).item())
    # forward: 1,1,1; градиенты: -1,-inf,-0

[Пробник](probe.py), [результаты установленного wheel](wheel-reproduction.json).
Все входы здесь конечны, знаменатели ненулевые.
Это нарушение точного тождества у гладкой составной функции.

## Первый порядок в четырёх dtype

В точке (a,b)=(C,C) для a/b ожидается градиент [1/C,−1/C].
По числителю исходный MLX его вычисляет корректно, по знаменателю — нет.

В float16 при C=1024 исходный VJP равен [1/1024,−0] вместо
[1/1024,−1/1024]. При C=2⁻¹⁴ он равен [16384,−inf] вместо
[16384,−16384]. Ожидаемые значения находятся в нормальном диапазоне dtype.

JVP по знаменателю имеет тот же дефект. Совместный JVP в направлении
(C,C) должен быть 0, поскольку общий масштаб сокращается; исходный
runtime возвращает NaN на обоих крайних масштабах.
Аналогичные случаи воспроизведены в bfloat16, float32 и float64.

## Причина и изменение

Публичные
[Divide::vjp](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/mlx/primitives.cpp#L1806)
и
[Divide::jvp](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/mlx/primitives.cpp#L1857)
в вещественном случае вычисляют вклад по знаменателю как −(g·a)/(b²).
Квадрат b или произведение g·a может выйти из диапазона раньше,
чем итоговая производная. Патч устраняет эти промежуточные операции
в выбранных опасных режимах.

[Исследовательский патч](divide-scale-autodiff.patch) оставляет
производную по числителю и прежние комплексные формулы.
Новая ветвь применяется только после приведения аргументов к
вещественному dtype. [Операция divide](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/mlx/ops.cpp#L3128)
сама выполняет общее приведение типов и broadcasting.

Обозначим через L тот из множителей a,g, чей модуль больше, через S —
другой множитель; знаки сохраняются. Выбирается один из трёх порядков:

| Условие | Вычисление вклада по знаменателю |
|---|---|
| abs(b)≥1 и abs(S)≥abs(b) | −(L/b)·(S/b) |
| abs(b)≥1 и abs(S)<abs(b) | −((L/b)·S)/b |
| abs(b)<1 и L/b конечен | −(L/b)·(S/b) |
| abs(b)<1 и L/b переполняется | −((L·S)/b)/b |

Все выражения равны −g·a/b². Выбор реализован через делители перед
арифметикой; не требуется смешивать уже вычисленные опасные ветви.
Stop_gradient используется только для выбора порядка. Реальные
числитель, знаменатель и вес в арифметике остаются дифференцируемыми.

Одной постоянной перестановки операций недостаточно. В серии отдельно
проверяются случаи, где:

- a/b исчезает при округлении, но большой g восстанавливает
  представимую производную; например a=2⁻¹²⁰,b=2³²,g=2¹²⁰ в float32.
- g/b переполняется, но произведение с малым a даёт конечный результат:
  a=2⁻¹⁰⁰,b=2⁻²⁰,g=2¹²⁰.
- a·g и b² переполняются, хотя их отношение представимо:
  a=g=2¹⁰⁰,b=2⁷⁰.
- a·g и b² исчезают, хотя их отношение представимо:
  a=g=2⁻¹⁰⁰,b=2⁻⁸⁰.

Эталоны этих случаев вычисляются как точные степени двойки,
не повторяя порядок операций исправления. Есть также перестановка
масштабов a и g, отрицательные числители и отдельные варианты dtype.

## Основная нативная серия

| Проверяемый dtype | Сравнения | Несовпадения до | Несовпадения после |
|---|---:|---:|---:|
| float16 | 280 | 114 | 0 |
| bfloat16 | 280 | 122 | 0 |
| float32 | 376 | 182 | 0 |
| float64 | 376 | 182 | 0 |
| complex64 — контроль прежней ветви | 16 | 0 | 0 |
| Всего | **1328** | **600** | **0** |

Это число сравнений, а не самостоятельных дефектов.
[Исходный тест](native_regression.cpp), [до](run-before.json),
[после](run-after.json), [скрипт](build_and_test.py),
[команды и время](build-results.json).

- JVP/VJP по каждому аргументу и совместно; три масштаба, разные
  знаки, нулевой числитель, веса 0,1,−0.5.
- Инвариант общего масштабирования и направление противоположного
  масштабирования числителя и знаменателя.
- Для float32/float64: первые три производные 1/t в шести точках;
  смешанный гессиан a/b при (a,b)=(1,2).
- Broadcasting (2,1) с (1,3), матрицы 2×3 и неплотные входы.
- Семь комбинаций показателей a,b,g для каждого вещественного dtype,
  с обоими знаками числителя.
- Умеренные complex64-входы и комплексные веса: JVP и VJP проверены
  независимой арифметикой std::complex<double>, включая сопряжения.

Нулевой первый градиент проверен отдельно. Для
R(t)=(C/(Ct)−1)² при t=1 правильны производные 0,2,−12.
Для I(t)=(Ct)/(Ct) правильны 0,0,0. Исправление проходит эти случаи
на всех трёх масштабах float32/float64 и не теряет ненулевой гессиан R.

Относительные допуски ненулевых эталонов: 5e−13 в float64,
2e−5 в float32, 3e−3 в float16, 2e−2 в bfloat16; компоненты
complex64 проверяются с допуском 8e−6. При нулевом эталоне
используется соответствующий абсолютный допуск.
Превращение любой проверенной ненулевой производной в 0 даёт
относительную ошибку 1 и не может скрыться за допуском.

## Совместимость с предыдущими исправлениями

Поскольку Divide входит в производные других операций, три локальных
патча объединены в одном primitives.cpp: деление, arcsinh/arccosh,
arctan2. Повторно запущены реальные ранее скомпилированные тесты:

| Предыдущая серия | Сравнения | Несовпадения с новым Divide |
|---|---:|---:|
| arcsinh/arccosh | 518 | 0 |
| arctan2 | 1368 | 0 |
| Всего | **1886** | **0** |

[Скрипт совместимости](compatibility.py),
[команды и время](compatibility-build-results.json),
[зависимости](compatibility-metadata.json),
[arcsinh/arccosh](compat-run-hyperbolic.json),
[arctan2](compat-run-arctan2.json).
Это проверка трёх исследовательских патчей вместе, не полной библиотеки.

## Версии и ограниченный поиск дубликатов

- Wheel: MLX 0.32.2, CPU.
- Нативная база: ce916dbbcaa88e433b6fd1e60a17f766d49c27fe.
- Публичный main: 81ba1c6a0e50a9268b931579c2d4f1158b9aab5a.
- Целевые Divide::vjp/jvp совпадают между базой и сохранённым main.
- [Публичные источники и SHA-256](source-metadata.json),
  [метаданные нативной сборки](native-source-metadata.json).

Четыре запроса GitHub API завершились полными ответами; найдено
18 уникальных issues/PR. [Сохранённые ответы](duplicate-search.json).

Релевантные соседние записи:

- [#2178](https://github.com/ml-explore/mlx/pull/2178) исправляет
  комплексные VJP, включая деление; прежняя комплексная формула сохранена.
- [#3733](https://github.com/ml-explore/mlx/pull/3733) относится
  к нулевому cotangent в sqrt/rsqrt при сингулярном входе.
  Здесь знаменатель ненулевой и математические производные конечны.
- [#2451](https://github.com/ml-explore/mlx/issues/2451) — другая
  прежняя ошибка формулы arctan2.
- [#2140](https://github.com/ml-explore/mlx/issues/2140) обсуждает
  предупреждения NumPy в тестах, а не этот инвариант Divide.
- Остальные результаты относятся к оптимизаторам, вниманию,
  расходу памяти, distributed, divmod/floor_divide и загрузке GGUF.

В прочитанных релевантных описаниях точного дубликата не обнаружено.
Это не доказательство приоритета: все комментарии и внешние обсуждения
не проверялись. Security-impact и право на выплату не установлены.

## Границы результата и нагрузка

Проверены указанные CPU-серии. GPU, compile, vmap, производительность,
нулевой знаменатель, NaN/±inf, полный субнормальный диапазон и все
сочетания крайних входов не проверены. Исправление комплексных
переполнений не входит в пакет. Конечная серия не доказывает полную
численную устойчивость всех производных MLX.

Патч увеличивает число операций и размер графа производной.
Он является прототипом корректности; скорость и пригодность
для принятия сопровождающими не установлены.

Использован существующий CPU-архив и отдельный primitives.cpp перед ним
при линковке. Полной чистой сборки текущего main не выполнялось.
Скрипты требуют уже существующих зависимостей по сохранённым путям.
Рабочая копия MLX с прежними изменениями не редактировалась.

Все компиляции и тесты последовательны; численные потоки ограничены одним.
Импорту wheel требовалась системная инициализация Metal, после которой
явно выбран CPU. GPU-вычислений не было; нативный архив без Metal.
Последняя основная серия со сборкой заняла около 2.78 секунды CPU,
проверка совместимости со сборкой — около 1.90 секунды CPU,
wheel-пробник — около 0.0053 секунды CPU.
Эти времена не являются сравнительным benchmark производительности.

[Проверка целостности пакета](validate_artifacts.py),
[результаты](artifact-check-results.json).
