MOffice

Спайк A8: движок кистей — libmypaint через FFI или порт на Rust

Сессия A, 2026-10-04. Задача A8 брифа docs/sessions/A-kernel.md («libmypaint — FFI или порт»). Предложение решения — раздел 6. Стенд — kernel/spikes/mypaint-port (в продукт не входит): порт render_dab_mask и draw_dab_pixels_BlendMode_Normal, побитная сверка с C, замеры; сырые данные — kernel/spikes/mypaint-port/results-2026-10-04-m5max.txt. Предыдущий разбор без замеров — docs/research/oss-graphics-pdf-charts.md §3.1.

1. Вопрос

Paint (и перо Notes) берёт движок кистей libmypaint и 196 кистей mypaint-brushes (docs/00-foundation.md, строка Paint). Нужно выбрать: FFI — немодифицированный C внутри процесса, или порт — перенос на Rust в meridian-raster (docs/03-architecture.md: «растровые слои, кисти, тайлы 256²»). Критерии: лицензия, сборка на трёх ОС, скорость, одинаковый результат на всех платформах (файлы Paint и тесты корпуса сравниваются попиксельно), безопасность разбора чужих данных, объём работы.

2. Что сделано

  1. Склонированы только зеркала meridian-oss/libmypaint (коммит d5a88fb от 2026-04-14, v2.0.0-beta.1-195) и meridian-oss/mypaint-brushes (08da4a4, v2.0.2-1). Из сети — только крейт cc.
  2. Ядро libmypaint собрано системным cc без autotools: 11 файлов, нужных для мазка по тайлам, — без json-c и glib, с ручным config.h в одну строку. Полная библиотека (16 файлов) — с json-c из системы и заголовками, сгенерированными generate.py (python3).
  3. Порт на Rust render_dab_mask (с calculate_rr, calculate_rr_antialiased, calculate_opa и др.) и draw_dab_pixels_BlendMode_Normal — src/dab_mask.rs, src/blend.rs; заголовки «Основано на libmypaint (ISC)», NOTICE с текстом лицензии и списком перенесённого. ≈ 290 строк C → 400 строк Rust (с двуязычными комментариями, после rustfmt).
  4. Сверка порта с C: эталон — немодифицированные .c из зеркала, собранные build.rs (крейт cc, -O2) и вызываемые через FFI. Входы — детерминированный генератор (все параметры даба — целое / 2^k, без операций с плавающей точкой), повторён в c/bench.c для чистого C. Тест tests/checksums.rs проверяет контрольные суммы порта без C.
  5. Замеры: микроядро (маска, маска + Normal) — Rust, C через FFI, чистый C; полный путь FFI — все 196 кистей на записи tests/events/painting30sec.dat (30 с рисования, 2 937 событий); кросс-проверка на x86_64 (Rosetta на Mac и Linux-сервер sagr с gcc/glibc); санитайзеры ASan/UBSan на широких и «диких» входах.

3. Разбор libmypaint и mypaint-brushes

3.1 Лицензии

Что Лицензия Класс Заметки
libmypaint (COPYING) ISC («Copyright (C) 2008-2011 Martin Renold and contributors») 1 заголовки .c/.h ядра — тот же текст ISC; GPL/LGPL/MPL в исходниках нет (поиск нашёл только m4-макросы autotools)
fastapprox/*.h (быстрые pow/exp/log, Paul Mineiro, 2011) BSD-3-Clause 1 нужен только fastpow для спектрального режима
rng-double.c (Кнут), fifo.c общественное достояние 0
m4macros/ax_check_*.m4 GPL с исключением Autoconf — только сборка autotools; не берём
po/*.po «same license as the MyPaint package» (GPL-2+) — не берём, переводы свои
json-c 0.19 (зависимость) MIT 1 только для разбора .myb
mypaint-brushes brushes/** (196 .myb + превью) CC0-1.0 (по Licenses.dep5, по авторам) 0 Files: * (сборочные файлы, Licenses.md) — GPL-2+, не берём

Итог: и FFI, и порт допустимы политикой (классы 0–1). Для порта нужна атрибуция ISC в NOTICE продукта и запрос B на запись в third_party.toml (порт чужого кода — по правилу брифа).

3.2 Сборка и зависимости

Обязательна? Факт (замер на d5a88fb)
autotools (autoconf, automake, libtool, intltool) нет 11 файлов ядра и mypaint-brush.c собираются cc -O2 -std=c99 с config.h из одной строки #define MYPAINT_CONFIG_USE_GLIB 0
python3 (generate.py) да, при сборке генерирует mypaint-brush-settings-gen.h (145 строк) и brushsettings-gen.h (96) из brushsettings.json; можно закоммитить готовые
json-c да для mypaint-brush.c #include <json.h> безусловный (стр. 37), поле brush_json в структуре кисти, разбор — стр. 1551–1690 (≈ 140 строк). Без json-c: fatal error: 'json.h' file not found. Остальные 15 файлов json-c не используют
glib / gobject-introspection нет MYPAINT_CONFIG_USE_GLIB 0 (заглушки типов в mypaint-glib-compat.h)
GEGL нет отдельный каталог gegl/
gettext нет --disable-i18n; без HAVE_GETTEXT строки не переводятся
OpenMP нет --enable-openmp — параллельно по тайлам в end_atomic/get_color

Полное ядро с json-c: 64 291 байт text+data (arm64, -O2); 15 файлов без json-c (всё, кроме mypaint-brush.c) — 0,76 с сборки последовательно. Предупреждение clang в brushmodes.c:580: (int)(random_sample_rate * RAND_MAX) — RAND_MAX округляется во float до 2^31, при rate = 1 получается переполнение int.

3.3 Объём кода (wc -l, с комментариями)

Файл Строк Что внутри Для порта
mypaint-brush.c 1 698 динамика кисти: 18 входов, 65 параметров, 44 состояния, stroke_to, размазывание, разбор JSON да (JSON — на serde)
mypaint-tiled-surface.c 974 маска даба (render_dab_mask и помощники ≈ 270 строк — перенесено), draw_dab, get_color, симметрия дабов маска — да; диспетчер — свой под тайлы 256²
brushmodes.c 626 8 функций наложения: Normal (перенесена), Normal_Paint (спектральная), Normal_and_Eraser (+Paint), LockAlpha (+Paint), Color, Posterize; get_color_pixels_accumulate да
helpers.c 608 HSV/HSL/HCY, спектральное смешивание, rand_gauss да
operationqueue.c + tilemap.c + fifo.c + mypaint-fixed-tiled-surface.c 255 + 89 + 114 + 152 очередь дабов по тайлам 64×64 нет, своя
mypaint-mapping.c 196 кусочно-линейные кривые «вход → параметр» да
mypaint-symmetry.c + mypaint-matrix.c 161 + 92 4 вида симметрии да
rng-double.c 136 генератор Кнута да
остальное (mypaint-surface.c, -rectangle.c, -brush-settings.c, mypaint.c) 318 интерфейсы, таблицы частично
итого .c / .h 5 419 / 945 16 файлов .c, 19 .h
fastapprox/*.h 3 323 нужен fastpow (≈ 30 строк) да
brushsettings.json + generate.py 826 + 349 описание параметров таблицы на этапе сборки

3.4 Публичный API, потоки, детерминизм

4. Замеры

Условия. Apple M5 Max, 18 ядер, 128 ГБ, macOS (Darwin 27); Apple clang 21.0.0 (clang-2100.3.34.2), rustc 1.97.1, release (opt-level=3, thin LTO), эталон C — -O2 (как в autotools libmypaint). Машина была загружена другими сессиями (load average 13–14 при финальном прогоне), поэтому основная оценка — минимум из 15 повторов, в скобках — медиана; реализации чередуются внутри каждого повтора. Сценарии: дабы со случайными центром (с выходом за тайл на полрадиуса), жёсткостью 0,05–1, мягкостью 0–0,8, вытянутостью 1–4, углом 0–360° на тайле 64×64 RGBA16 со случайными премультиплицированными пикселями; aa — r 0,5–3 (ветка со сглаживанием r < 3), small — 3–8, medium — 8–24, large — 24–64. Всё — одной командой run.sh (раздел 8).

4.1 Побитная сверка порта с C (350 000 дабов)

Сценарий Дабов C по умолчанию (clang, FMA): дабов с другой маской макс. |Δ| маски (из 32 768) Normal на общей маске: u16 ≠ C с -ffp-contract=off: маски ≠ / тайл ≠
aa_r0.5-3 200 000 2 352 (1,2 %) 3 0 0 / 0
small_r3-8 100 000 5 011 (5,0 %) 2 0 0 / 0
medium_r8-24 40 000 9 043 (22,6 %) 3 0 0 / 0
large_r24-64 10 000 5 037 (50,4 %) 2 0 0 / 0

4.2 Один и тот же C на разных компиляторах и ISA (контрольные суммы чистого C, c/bench.c)

Сборка aa small medium large Совпадает с портом
Rust-порт (arm64, любой; тест tests/checksums.rs) d4c89585… 946ea325… d1843964… cd861ab8… —
clang arm64 -ffp-contract=off d4c89585… 946ea325… d1843964… cd861ab8… да
clang x86_64 (Rosetta), без -mfma d4c89585… 946ea325… d1843964… cd861ab8… да
gcc 13.3 x86_64 Linux/glibc, -std=c99 (± -march=native, ± -ffp-contract=off) d4c89585… 946ea325… d1843964… cd861ab8… да
clang arm64 по умолчанию c4f8a61e… 8fc3d6fd… 633dd449… a59ae0f8… нет
clang x86_64 -mfma -mavx2 c4f8a61e… 8fc3d6fd… 633dd449… a59ae0f8… нет
gcc 13.3 x86_64 -std=gnu99 -march=native 626f55fe… ced57ab4… 8a29ddcc… a00647d4… нет

Немодифицированный C даёт три разных результата в зависимости от компилятора и флагов: gcc в режиме GNU C (умолчание autotools libmypaint — -std не задан) использует -ffp-contract=fast, clang — on, в режиме ISO C у gcc — off. Без FMA результат одинаков на arm64 и x86_64, на Apple libm и glibc (sin/cos от этих углов после округления до float совпали). Цена фиксации -ffp-contract=off для C — 1,5–3,3 % скорости маски в чистом C (4–9 % в замере через FFI, другой прогон; таблица 4.3).

4.3 Скорость микроядра, нс на даб (минимум из 15; в скобках медиана)

C (FFI) — немодифицированный C, собранный cc с -O2, вызов из Rust; чистый C — c/bench.c, без Rust.

Сценарий Пикс./даб Маска: Rust Маска: C (FFI) Маска: чистый C Маска+Normal: Rust Маска+Normal: C (FFI) Normal, нс/пикс.: Rust / C
aa_r0.5-3 12,2 154 (156) 158 (159) 158 (165) 173 (174) 175 (176) 1,55 / 1,40
small_r3-8 65,2 232 (236) 265 (268) 271 (284) 325 (331) 340 (346) 1,42 / 1,15
medium_r8-24 396,2 909 (916) 1 019 (1 030) 1 051 (1 061) 1 378 (1 399) 1 435 (1 448) 1,18 / 1,05
large_r24-64 1 472,4 2 446 (2 487) 2 606 (2 618) 2 635 (2 802) 4 088 (4 129) 4 009 (4 038) 1,12 / 0,95

То же при C с -ffp-contract=off (побитно равные результаты): маска Rust 156 / 235 / 905 / 2 428 против C 164 / 281 / 1 109 / 2 733; маска+Normal 174 / 324 / 1 379 / 4 076 против 180 / 358 / 1 531 / 4 175. Чистый C без FMA: маска 162 / 275 / 1 072 / 2 723 (против 158 / 271 / 1 051 / 2 635 с FMA).

Первый, «дословный» порт (циклы for xp in x0..=x1 с индексом) был медленнее C в 1,08–1,38 раза (маска 215 / 285 / 1 182 / 3 389 против 156 / 264 / 1 010 / 2 566): clang векторизует цикл rr в C (93 векторные инструкции .4s в render_dab_mask), а LLVM для Rust — нет (0) из-за RangeInclusive и проверки границ. Переписывание циклов через срезы строки (результат тот же побитно) дало векторизацию и скорость из таблицы.

4.4 Полный путь FFI: libmypaint + json-c, 196 кистей × painting30sec.dat, холст 1024×1024

c/stroke.c: «динамика» — stroke_to без get_color; get_color — выборка цвета для размазывания (сама дорисовывает очередь дабов в затронутых тайлах и строит маску); «растр» — end_atomic (маски и режимы по тайлам).

Кистей Дабов Время, мс нс/даб из них динамика / get_color / растр
все 196 (загрузились все) 19 682 173 17 518 890 287 / 406 / 196
с размазыванием (get_color на каждый даб) 95 11 639 989 13 303 1 143 (медиана по кистям 1 283) get_color — 60 % времени
без размазывания 101 8 042 184 4 215 524 (медиана 393) 192 / 0 / 332

Примеры: classic/pencil — 89 083 даба, 25,6 мс, 287 нс/даб; classic/charcoal — 351; deevad/watercolor_glazing — 2 217 (get_color 240 из 274 мс); deevad/airbrush — 1 547 дабов по 63 мкс (крупные мягкие дабы, растр). Самая тяжёлая кисть набора (Dieterle/Tail_Feathers2) — 714 мс на 30 с записи, ≈ 2,4 % одного ядра; медиана — 744 нс/даб. 12 кистей Dieterle используют вход surfacemap_x, которого нет в libmypaint d5a88fb (12 предупреждений «Unknown input_id»; кисти грузятся, вход игнорируется).

4.5 Безопасность (санитайзеры, 300 000 вызовов маски + Normal)

5. Выводы

  1. Скорость не решает выбор. Порт микроядра быстрее C на маске на 3–12 % и равен ему на маске + Normal (от −4 % до +2 %); накладные FFI на даб не видны (C через FFI = чистый C в пределах шума). На полном пути самая тяжёлая из 196 кистей занимает ≈ 2,4 % ядра на 30 с рисования. Векторизация в Rust требует внимания (циклы по срезам), но даётся и не меняет результат.
  2. Детерминизм — главный аргумент за порт. Один и тот же C даёт три разных холста в зависимости от компилятора (clang/gcc), режима языка и наличия FMA. FFI потребует жёстко фиксировать -ffp-contract=off на всех платформах (дёшево: единицы процентов скорости) — но остаются rand() libc в размазывании и libm в динамике, которые FFI не исправит без правки C. Rust не сливает операции в FMA никогда, порт побитно равен C без FMA и одинаков на arm64 и x86_64; rand() заменяется своим генератором, libm — чистым Rust (крейт libm) там, где нужна одинаковость между ОС.
  3. FFI без json-c невозможен без правки C: mypaint-brush.c безусловно включает json.h и хранит JSON кисти. Разбор .myb всё равно лучше делать на Rust (serde) через mypaint_brush_set_base_value/set_mapping_*, но json-c при FFI остаётся в сборке на трёх ОС (или нужен патч ≈ 140 строк). Сборка без autotools проста (cc + config.h + generate.py/готовые заголовки).
  4. Безопасность: C-ядро не выходит за память на диких входах, но содержит UB, а разбор .myb идёт через json-c — при FFI нужен фаззинг C под санитайзерами (cargo-fuzz + cc) и проверка входов на стороне Rust.
  5. Порт малого объёма и хорошо проверяем: перенос маски и Normal (≈ 290 строк C) занял часы, а дифференциальная сверка с C (немодифицированный C через cc в тестах + контрольные суммы) ловит отличие в одну единицу fix15. Тот же приём масштабируется на весь движок: 196 кистей × painting30sec.dat.
  6. Нагрузка — в динамике и размазывании, не в растре. 78 % времени полного пути — stroke_to и get_color; размазывание (95 из 196 кистей) в 2,2 раза дороже обычных кистей на даб. Значит, порт должен покрыть mypaint-brush.c целиком, а оптимизировать — выборку цвета (get_color) и очередь дабов, а не только маску.

6. Решение (предложение ADR-A8, 2026-10-04)

Контекст. Paint и перо Notes используют кисти MyPaint (196 кистей CC0); холсты должны быть одинаковыми на Windows, macOS и Linux (тесты корпуса, обмен файлами); процесс редактора не должен содержать C без нужды.

Решение.

  1. Порт на Rust движка libmypaint в meridian-raster (модуль brush): динамика (mypaint-brush.c), кривые, симметрия, маска даба и все 8 функций наложения, rng-double, fastpow. Очередь дабов и тайлы — свои (256², формат пикселей meridian-raster); формулы маски и режимов — дословно, с заголовками «Основано на libmypaint (ISC)» и NOTICE.
  2. FFI не используем в продукте. Немодифицированная libmypaint из зеркала собирается крейтом cc только в тестах как эталон (-ffp-contract=off): дифференциальные тесты «порт = C» побитно на всех 196 кистях × painting30sec.dat (как в спайке). json-c в тестах — системный или заглушка.
  3. Правила детерминизма для meridian-raster: без mul_add/FMA-интринсиков и без «быстрой математики»; генератор случайности — свой с явным зерном вместо rand(); трансцендентные функции в путях, влияющих на пиксели, — через крейт libm (чистый Rust); контрольные суммы холста — в тестах на трёх ОС.
  4. Разбор .myb — serde_json (json-c не нужен); вход surfacemap_x из новых кистей — поддержать при порте.

Для библии (одна-две фразы): «Кисти Paint — порт libmypaint (ISC) на Rust в meridian-raster с побитной сверкой с C в тестах; C-код libmypaint в продукт не входит. Растр кистей детерминирован между ОС: без FMA, без rand() libc, математика — libm.»

7. Объём порта по модулям (оценка, чел.-дни)

Модуль Строк C Оценка Комментарий
маска даба (render_dab_mask и помощники) ≈ 270 0 (сделано) перенос + сверка — в спайке
режимы brushmodes.c (ещё 7 + get_color_pixels_accumulate) 600 2–3 спектральные режимы с fastpow
helpers.c (цветовые модели, спектр, rand_gauss) 608 2
mypaint-brush.c (динамика, без JSON) ≈ 1 560 6–8 основной объём и основной риск расхождений (libm)
mypaint-mapping.c 196 0,5
симметрия + матрицы 253 1
rng-double.c + fastpow ≈ 170 0,5
таблицы параметров из brushsettings.json (вместо generate.py) 826 JSON 1 build.rs или константы
загрузка .myb на serde + 196 кистей CC0 в ресурсах ≈ 140 1
своя очередь дабов и тайлы 256² вместо operationqueue/tilemap/fifo ≈ 610 2–3 под формат meridian-raster
дифференциальные тесты 196 кистей против C, контрольные суммы на 3 ОС — 2–3 стенд спайка — основа
итого ≈ 5 000 18–23 чел.-дня (≈ 1 чел.-мес.) без SIMD-оптимизации под 256² и без новых типов кистей

Для сравнения, FFI (оценка): обёртка MyPaintSurface над тайлами meridian-raster с конвертацией fix15 ↔︎ формат слоёв, сборка cc на трёх ОС с json-c или патчем, фаззинг под санитайзерами, фиксация флагов — 7–10 чел.-дней, но без решения проблем rand()/libm и с C в процессе редактора.

8. Риски

  1. libm в динамике: переход на крейт libm даст одинаковость между ОС, но отличие от libmypaint (эталон в тестах использует системную libm) — сверку динамики придётся вести с допуском или собирать эталон с той же libm (оценка: подменить expf/powf/… в C через -D-макросы на вызовы Rust libm по FFI).
  2. Размазывание в оригинале недетерминировано (rand()); эталон для 95 кистей — только при подмене rand в тестовой сборке C (макрос), иначе сравнение статистическое.
  3. Тайлы 256² вместо 64² меняют порядок обработки дабов в get_color (дорисовка очереди) — возможны отличия от libmypaint на кистях с размазыванием; сверять на тайлах 64² и отдельно проверять независимость от размера тайла.
  4. Векторизация в Rust не гарантирована (пример 4.3): нужны бенчмарки в CI с порогом, при необходимости — явный SIMD (std::simd/wide) с теми же поэлементными операциями (без FMA).
  5. Набор кистей и версия движка: 12 кистей используют вход surfacemap_x, неизвестный ревизии d5a88fb.
  6. Отладочные паники порта на диких входах (переполнение skip) — в продуктовом коде ранний выход для дабов вне тайла и проверка координат на входе.

9. Как повторить

# всё сразу: клоны зеркал в $WORK, сборка C без autotools, сверка, замеры, 196 кистей; ≈ 1–2 мин
WORK=$TMPDIR/mypaint-spike REPS=15 kernel/spikes/mypaint-port/run.sh results.txt

# по отдельности (MYPAINT_SRC — клон meridian-oss/libmypaint на d5a88fb):
cd kernel/spikes/mypaint-port
MYPAINT_SRC=$WORK/libmypaint MYPAINT_FP_CONTRACT=off cargo run --release -- verify   # побитно = C
MYPAINT_SRC=$WORK/libmypaint cargo run --release -- verify                           # C с FMA: расхождения
MYPAINT_SRC=$WORK/libmypaint cargo run --release -- bench 15                         # скорость Rust и C (FFI)
cargo test --release                                                                 # контрольные суммы порта без C

Кросс-проверка на Linux (раздел 5 файла результатов) выполнена вручную на sagr: те же 11 файлов ядра и c/bench.c, gcc -O2 -std=c99|gnu99 [-march=native] [-ffp-contract=off]; временный каталог удалён.