К ленте · Россия

РоссияНовостьМодели

Ты не бойся, это Goose

Содержание Введение Как это работает Где компилятор бьёт по рукам За что боролись (магия) Саморазоблачение (бенчмарки) Цена вопроса: замыкания Цена вопроса: передача данных в потоки Цена вопроса:...

Содержание

  • Введение
  • Как это работает
  • Где компилятор бьёт по рукам
  • За что боролись (магия)
  • Саморазоблачение (бенчмарки)
  • Цена вопроса: замыкания
  • Цена вопроса: передача данных в потоки
  • Цена вопроса: индексирование массивов
  • Итак, переходим на Goose?
  • Источники и дополнительные материалы

Введение

Язык и компилятор Goose спроектировал Wouter van Oortmerssen. Согласно странице проекта, реализация компилятора, бенчмарки, примеры и документация созданы с помощью ИИ-модели Claude Fable. В чём автор был замечен ранее: ЯП Lobster, разработка которого началась в 2010 году, и библиотека сериализации FlatBuffers, опубликованная Google в 2014-м.

Бэкграунд у автора вполне солидный, посмотрим же на “гуся” в деталях.

Основная фишка Goose связана с управлением памятью. К системе управления памятью можно предъявить три нефункциональных требования на букву “Б”:

  • Безопасно
  • Без затей: управление памятью не требует сложных действий типа явных аннотаций времени жизни
  • Быстро: выделение и освобождение памяти требуют минимальной и предсказуемой работы во время выполнения

В грубом приближении некоторые языки, с которыми я знаком, расставляют акценты так:

Язык

Безопасно

Без затей

Быстрое выделение и освобождение памяти

Go

◐

⬤

◔

JavaScript

◐

⬤

◔

Dart

⬤

⬤

◔

Rust

⬤

◯

⬤

⬤ – да; ◐ – с оговорками; ◔ – лишь частично; ◯ – нет.

Пояснения к таблице:

  • Go и JavaScript: без затей и с базовыми гарантиями безопасности памяти. В обычном Go-коде без unsafe и в управляемой памяти JavaScript исключены проблемы типа dangling pointers и use-after-free, но в обоих языках возможны гонки данных: в Go – между goroutines, работающими с общей памятью, в JavaScript – между agents (например, Web Workers), обращающимися к одному SharedArrayBuffer. Выделение памяти в системах с GC обычно дёшево, но поиск и освобождение недостижимых объектов расходуют процессорное время и могут создавать latency spikes. То есть требование 1 выполняется частично, 2 – да, 3 – с проблемами.
  • Dart: без затей и с гарантиями. Гонки данных над управляемой памятью в чистом Dart-коде исключены моделью isolates. Понятно, эта гарантия не распространяется на FFI и JavaScript-интероп. Есть GC, со всеми плюсами и минусами. То есть требования 1 и 2 выполняются, а 3 – частично.
  • Rust: safe Rust обеспечивает безопасность памяти и исключает гонки данных, при этом работает без GC. За статические гарантии приходится платить “магическими рунами” в тексте: fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b } }. Ещё больше “затей” возникает с замыканиями; в своё время я разбирал эту тему и предлагал упрощение. Выходит, что требования 1 и 3 выполняются, а 2 – нет.

Тут напрашивается очередная неформальная трилемма: “одновременно можно получить максимум два качества из трёх”.

Goose пытается сломать этот шаблон, предлагая все три качества и даже добавляя четвёртое: “Бережно”, то есть с минимальными расходами памяти на служебные данные.

Как это работает

Проще всего представить управление памятью Goose как арены. Кто писал, к примеру, на Go или Rust, тот знает про такую концепцию. Данные с общим временем жизни здесь освобождаются вместе. Каждая такая логическая арена может использовать несколько data stacks – независимых линейных аллокаторов. У каждого стека есть курсор выделения top, то есть bump-указатель на следующий свободный байт. Компилятор заранее вычисляет, сколько data stacks одновременно понадобится программе.

Концептуально передача этого числа в runtime выглядит так:

// Компилятор рассчитал, что программе нужны три data stacks.
const int64_t N = 3;

// Runtime создаёт для них память.
gs_stack_block(N);

Runtime получает готовое число и резервирует по одной области памяти для каждого data stack (код сильно упрощён):

static void gs_stack_block(int64_t n) {
 // Создаём N описателей. Сама память стеков здесь ещё не резервируется.
 gs_stks = calloc(n > 0 ? n : 1, sizeof(gs_stack));

// Для каждого data stack резервируем отдельную область памяти.
 for (int64_t i = 0; i < n; i++) {
 gs_stks[i].top = gs_reserve_region(&gs_stks[i]);
 }

gs_nstks = n;
}

Итого: компилятор считает N, а runtime N раз вызывает gs_reserve_region. Эта функция резервирует большой диапазон виртуальных адресов: в Linux через mmap, в Windows через VirtualAlloc. По умолчанию на каждый data stack резервируется 2 ГБ адресного пространства. Реальная память подключается по мере использования. Guard-область в конце останавливает переполнение. Исходный код runtime.

Выделение памяти внутри data stack – это запись по адресу top и сдвиг top вперёд. Например, a сразу создаётся литералом, b строится циклом, а затем растёт c:

fn make_b() -> i32[] {
 var result: i32[>..] = [];
 for i: i32 in 5 {
 result.push(i);
 }
 return result;
}

fn main() {
 var a: i32[] = [1, 2, 3];
 var b: i32[] = make_b();
 var c: i32[>..] = [];

for i: i32 in 1000 {
 c.push(i);
 }

print(a.len, b.len, c.len);
}

Внутри make_b массив result растёт обычным циклом. Компилятор строит возвращаемый i32[] прямо на месте b, без промежуточной копии. Готовый b больше не resizable, поэтому поверх него можно разместить растущий c:

data stack 0: [ a ][ b ][ c растёт............. ] -> top

Массив c находится на вершине стека, поэтому может расти простым сдвигом top и никуда не переезжает. Правило Goose: на одном стеке может находиться только один “живой” resizable-массив. Если одновременно живут три таких массива, компилятор использует три стека:

var a: i32[>..] = [];
var b: i32[>..] = [];
var c: i32[>..] = [];

for i: i32 in 1000 {
 a.push(i);
 b.push(i);
 c.push(i);
}
data stack 0: [ a растёт................ ] -> top
data stack 1: [ b растёт................ ] -> top
data stack 2: [ c растёт................ ] -> top

Итого: Goose заранее рассчитывает необходимое число data stacks, быстро наращивает их bump-указатели и массово освобождает данные вместе с областью видимости. Формальные правила описаны в спецификации Goose.

Где компилятор бьёт по рукам

За простую модель памяти приходится платить ограничениями. Ниже – несколько характерных примеров.

Растущий массив должен быть последним полем обычной структуры. Иначе его рост затрёт следующее поле:

struct S {
 tail: i64[>..],
 after: i64,
}

error: resizable field tail of struct S must be the final field

Тест компилятора.

Ссылка или срез не может пережить данные, на которые указывает:

fn bad() -> i64[:] {
 var local: i64[1] = [42];
 return local[..];
}

error: returning a reference rooted in local, which dies with this function

Тест компилятора.

Нельзя уменьшать массив, пока ещё используется срез его данных:

var buf: u8[>..] = [65];
let s: const u8[:] = buf;
buf.pop();
print(s);

error: cannot pop buf while s is still used

Тест компилятора.

Наконец, нельзя повторно растить массив, пока на его вершине ещё строится новый элемент:

fn make(words: u8[][>..]&) -> u8[] {
 words.push("side effect");
 return "result";
}

var words: u8[][>..] = [];
words.push(make(words));

error: cannot call make, which grows words: the element pushed into words is still under construction, and the growth would land inside it

Тест компилятора.

За что боролись (магия)

Ниже – бенчмарки, в которых самый быстрый вариант Goose под Clang опередил и лучший safe Rust, и оптимизированный вручную C++ больше чем на 10%. Время измерено на размере large; память – пиковый рабочий набор (peak working set) той же реализации.

В таблице скорости gap показывает, во сколько раз Goose быстрее.

Бенчмарк

Goose, мс

Expert C++, мс

Gap

Safe Rust, мс

Gap

records

441,0

625,5

1,42×

891,8

2,02×

interp

67,8

93,8

1,38×

87,5

1,29×

words

1199,5

1987,8

1,66×

2084,0

1,74×

bintrees

203,8

406,9

2,00×

235,4

1,16×

В таблице памяти gap показывает, во сколько раз рабочий набор Goose меньше.

Бенчмарк

Goose, МБ

Expert C++, МБ

Gap

Safe Rust, МБ

Gap

records

149,0

556,9

3,74×

593,4

3,98×

interp

28,0

63,4

2,26×

57,0

2,04×

words

535,6

638,4

1,19×

675,9

1,26×

bintrees

31,8

35,8

1,13×

31,5

на 1% больше

Под expert C++ автор понимает не особые флаги компилятора, а вручную оптимизированное представление данных: арены с индексами вместо отдельных объектов, tagged union вместо иерархии с виртуальными методами, собственную open-addressing таблицу вместо unordered_map. Для Rust отдельной категории expert нет: все реализации остаются safe, но при наличии нескольких вариантов для сравнения выбирается самый быстрый. Поэтому быстрый Rust здесь тоже не всегда “наивный”: для деревьев это рекомендуемая Vec-арена с индексами u32, а для words – написанная вручную open-addressing таблица. Rust собран с -O -C codegen-units=1. Описание вариантов Rust.

  • records строит журнал из 16 миллионов variant-записей и четыре раза его обходит. Goose использует встроенный variable-size enum: каждый элемент занимает место только своего варианта, а короткий текст хранится inline. Expert-вариант C++ вручную заменяет std::string встроенным буфером, а Rust использует обычные enum и String; при этом каждый элемент Rust всё равно имеет размер крупнейшего варианта. Здесь выигрыш Goose получается из возможностей языка, без собственного аллокатора или ручной упаковки записей.
  • interp строит дерево выражений и восемь раз его вычисляет. Goose хранит листья и внутренние узлы разного размера в одном пуле и связывает их типизированными относительными ссылками. Быстрые C++ и Rust тоже переходят на арену, но хранят узлы фиксированного максимального размера и связывают их числовыми индексами. Пул в Goose указан явно, зато код не реализует индексацию и проверку принадлежности ссылок вручную.
  • words считает частоты в тексте из 16 миллионов слов. Здесь важно не преувеличивать: самый быстрый вариант Goose, как и варианты C++ и Rust, содержит написанную вручную open-addressing таблицу. Однако вариант Goose со стандартным dictionary всё равно показал 1473,4 мс и 423,6 МБ – быстрее приведённых в таблице C++ и Rust и экономнее по памяти. То есть ручная оптимизация увеличила преимущество, но не создала его с нуля.
  • bintrees многократно строит и выбрасывает бинарные деревья. Во всех трёх быстрых вариантах применяется арена. В Rust это Vec с индексами u32 и ручным clear(), в C++ – vector-арена, а в Goose – локальный массив с типизированными ссылками, который освобождается сбросом top при выходе из функции. Goose всё ещё требует выбрать структуру в виде пула, но не требует писать отдельную схему владения и освобождения каждого узла.

Goose часто выражает плотную компоновку, арены и ссылки между их элементами непосредственно средствами языка. Там, где в C++ и safe Rust приходится вручную переходить к буферам, индексам и специальным контейнерам, Goose требует заметно меньше вспомогательного кода. Бенчмарки отобраны только по скорости, а таблица памяти показывает даже и проигрыш: в bintrees Goose занял 31,8 МБ против 31,5 МБ у Rust.

Саморазоблачение (бенчмарки)

Тут надо сразу отметить, что автор Goose добросовестно указывает на проигрыши Goose, шум и ограничения методики. Поэтому “разоблачение” здесь относится скорее к текущему “информационному шуму” вокруг Goose.

  • В таблицы выше сознательно вошли только четыре убедительных выигрыша Goose, а не весь набор из 16 бенчмарков. Они показывают задачи, на которых модель памяти Goose раскрывается лучше всего, но не среднюю производительность языка.
  • Обобщённая таблица автора охватывает все 16 бенчмарков. В ней итоговые показатели скорости и памяти рассчитаны независимо и могут относиться к разным реализациям Goose, поэтому их нельзя воспринимать как характеристики одной программы.
  • Все измерения выполнены на одном компьютере без закрепления процесса за ядрами. Сам автор считает шумом различия менее чем в 10%, а для graph и lru – менее чем в 15%. Поэтому небольшие преимущества в отдельных строках нельзя считать доказанным превосходством языка.
  • Goose выигрывает не везде: calc быстрее на Rust, lru проигрывает Rust при компиляции через MSVC и выигрывает через Clang, а scene проигрывает под MSVC и примерно равен Rust под Clang. Плоские численные вычисления в particles практически одинаковы во всех трёх языках.

Цена вопроса: замыкания

В Goose можно передать именованную функцию:

fn each_pair<T, F>(xs: T[:]) {
 for i in 0..xs.len - 1 {
 F(xs[i], xs[i + 1]);
 }
}

fn print_pair(a: i64, b: i64) {
 print(a, " ", b);
}

fn main() {
 var xs: i64[] = [4, 8, 15, 16, 23, 42];
 each_pair(xs, print_pair);
}

F связывается с print_pair на этапе компиляции. Компилятор создаёт специализацию each_pair для этой функции, а вызов F(...) превращает в прямой вызов или встраивает в цикл. Function values существуют только во время компиляции: это не closure-объекты и не указатели на функции.

Вложенные функции и блоки могут захватывать локальные переменные, но не могут покинуть функцию, в которой объявлены. Function value нельзя сохранить в переменной или структуре, положить в массив, выбрать через if или вернуть из функции. Поэтому обычную фабрику замыканий написать не получится:

fn make_counter() {
 var n = 0;

fn next() -> i64 {
 n++;
 return n;
 }

return next;
}

error: function values cannot be returned

Цена такой модели – нельзя создавать долгоживущие callbacks, хранить обработчики событий или выбирать реализацию во время выполнения. Зато для map, filter, sort и похожих функций не нужны closure-объекты, косвенные вызовы и дополнительные аллокации. Подробнее это описано в спецификации Goose; ошибка проверяется отдельным тестом компилятора.

Цена вопроса: передача данных в потоки

Goose исключает data races в чистом коде весьма нехитрым способом: у потоков нет общей изменяемой памяти. Функция, объявленная как thread_fn, вместе со всем вызываемым ею кодом компилируется как отдельная программа со своими data stacks и собственными копиями глобальных переменных.

Например, worker увеличит только свою копию counter, поэтому main по-прежнему напечатает 0:

var counter = 0;

thread_fn increment() {
 counter++;
}

fn main() {
 thread_wait(thread_spawn(increment));
 print(counter); // 0
}

Задания и результаты передаются через типизированные очереди qput<T> и qget<T>. Через очередь могут проходить только плоские значения без ссылок и срезов: они лежат в памяти непрерывно и копируются одним memcpy. Поэтому языку не нужны locks, atomics и memory ordering. Подробнее это показано в документации Goose.

struct Job { n: i32 }
struct Result { n: i32 }

thread_fn worker() {
 let job = qget<Job>();
 qput(Result { n: job.n * 2 });
}

fn main() {
 let id = thread_spawn(worker);
 qput(Job { n: 21 });
 let result = qget<Result>();
 thread_wait(id);
 print(result.n); // 42
}

Цена такой гарантии – нельзя разделить между потоками изменяемый кеш или граф и нельзя передать ссылку на уже построенную структуру. Например, такой код не скомпилируется:

thread_fn worker(value: i64&) {
 print(value);
}

fn main() {
 var value = 42;
 thread_spawn(worker, &value);
}

error: thread_fn parameters must be flat, not i64&

Тест компилятора.

Цена вопроса: индексирование массивов

Если элементы имеют разный размер, у массива нет фиксированного шага. Чтобы найти entries[i], нужно прочитать размеры всех предыдущих элементов:

struct Entry {
 text: u8[],
}

fn main() {
 var entries: Entry[>..] = [];
 entries.push(Entry { text: "a" });
 entries.push(Entry { text: "much longer" });

for entry in entries { print(entry.text); } // работает
 print(entries[1].text); // ошибка
}

error: arrays of variable-size elements cannot be indexed, only iterated; for random access, keep your own array of references to them

Тест компилятора.

Goose не создаёт скрытую таблицу смещений: массив с элементами разного размера можно последовательно обходить, но нельзя индексировать. Если нужен произвольный доступ, индекс нужно поддерживать явно в виде второго массива ссылок:

struct Entry {
 text: u8[],
}

fn main() {
 var entries: Entry[>..] = [];
 var entry_at: (Entry&)[>..] = [];

entry_at.push(entries.push(Entry { text: "a" }));
 entry_at.push(entries.push(Entry { text: "much longer" }));

print(entry_at[1].text); // "much longer"
}

Таким образом, данные остаются плотно упакованными, а entry_at[i] обеспечивает произвольный доступ.

Официальный пример с четырёхбайтовым индексом.

Итак, переходим на Goose?

Вынесем за скобки зрелость проекта. Стоит ли переходить, да или нет? Нет. Или да. Зависит от контекста.

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

  • Парсеры, компиляторы
  • Пакетная обработка

Скорее всего, Goose будет неудобен там, где нужны:

  • Общая изменяемая память, atomics или lock-free структуры
  • Передача больших структур между потоками без копирования
  • Долгоживущие графы, в которых требуется автоматически обнаруживать обращения к удалённым или повторно использованным узлам: slot_pool<T> позволяет переиспользовать слоты, но контроль логического use-after-free и double free остаётся на приложении

Ну то есть Goose – не замена C++ или Rust, а язык для отдельных задач, где максимум можно выжать за счёт компоновки данных и управления памятью.

Источники и дополнительные материалы

  • Страница проекта и сведения об авторах
  • Исходный код Goose, commit b781587
  • Спецификация языка
  • Руководство по языку
  • Устройство набора бенчмарков
  • Результаты бенчмарков
  • Исходный код runtime
  • Тесты ошибок компилятора
  • Philosophy of Lobster
  • FlatBuffers: Memory Efficient Serialization Library
  • Замыкания в Rust и возможное упрощение
  • The Go Memory Model
  • ECMAScript Memory Model
  • Concurrency in Dart
  • Data Races and Race Conditions in Rust
Источник: Хабр · статьиПубликуется с разрешения правообладателя, подтверждённого редакцией.Оригинал: Ты не бойся, это Goose ↗

Есть кому отправить?

Скопируется постоянная ссылка на этот материал.

Комментарии

Обсуждайте статью, отвечайте и оценивайте комментарии. Вход — через ваш Яндекс ID.

Загружаем обсуждение…