Уроборос

Записывает, как код на самом деле исполнялся: вызовы, доводы, результаты, исключения, длительности

View the Project on GitHub digitable-lol/ouroboros

← к оглавлению

Замеры

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

Ни одно число на этой странице не переписано из другого места. Если вы запустите то же самое у себя, вы получите свои числа — они будут другими, потому что машина другая, — но соотношения между языками должны сохраниться.

Что мерялось

Одна и та же работа на всех восьми языках: функция add(a, b), вызванная 20 000 раз в цикле, плюс функция, которая бросает исключение (у Go — паникует), плюс охватывающая функция. Итого 20 002 вызова. У C++ их 20 003 — там ещё вызов, возвращающий объект классового типа; у Java и C# тоже 20 003, потому что там обмазывается и main: в этих языках нет уровня файла, куда можно было бы дописать пусковой код после обмазки, и он обмазывается вместе со всем остальным.

Для каждого языка снято:

На какой машине

что значение
процессор AMD EPYC 7742; nproc показывает 256
система Ubuntu 26.04 LTS, ядро Linux 7.0.0-29-generic, x86_64
файловая система рабочего каталога ext4
Python 3.14.4
Node v26.7.0
gcc / g++ 15.2.0
clang / clangd / clang-tidy 21.1.8
Elixir 1.20.3, Erlang/OTP 29
JDK OpenJDK 26-internal (javac 26-internal)
.NET SDK 10.0.110
Go 1.26.5
уроборос ouroboros-logger 0.4.0 — та версия, что стояла на момент замера

Обе сводные таблицы ниже сняты одним прогоном, все восемь языков сразу. Прежние их значения снимались на этой же машине более ранними выпусками, и при каждой пересъёмке прежние языки сходятся в пределах нескольких процентов (Python 66,2 → 63,5 → 58,2 мкс, JavaScript 20,8 → 20,3 → 20,4, C 20,3 → 19,8 → 19,9, C++ 22,8 → 22,0 → 22,2), кроме Elixir, у которого замер и раньше был самый неустойчивый. Собирать таблицу из разных прогонов поэтому нельзя: сравнивать между собой можно только строки, снятые вместе.

Это важно для чтения чисел. У шести помощников из восьми каждая запись — это open, дозапись и close файла, поэтому замеры упираются в файловую систему, а не в язык. Помощники Java и C# держат файл открытым и потому дешевле. На машине с медленным диском все числа времени вырастут, а соотношения между языками останутся.

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

Всё, что на этой странице, снимается одной командой:

scripts/measure/run.sh

Она копирует образцы из scripts/measure/samples/ в рабочий каталог дважды — как есть и обмазанными, — собирает обе копии, где нужна сборка, гоняет их по семь раз и печатает готовые таблицы. Языка, чьего средства сборки нет на машине, она не считает, а прямо об этом говорит. Число повторов меняется переменной OUROBOROS_MEASURE_REPEATS.

Сама замерялка — двадцать строк (scripts/measure/measure.py):

# measure.py <имя> <повторов> <файл записей или "-"> <рабочий каталог> -- <команда...>
times = []
for _ in range(repeats):
    if trace != "-" and os.path.exists(trace):
        os.remove(trace)
    t0 = time.perf_counter()
    subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True)
    times.append(time.perf_counter() - t0)

Файл записей стирается перед каждым повтором, иначе объём насчитывается за все повторы сразу. На этом легко ошибиться: первый прогон здесь дал 280 028 строк вместо 40 004 ровно по этой причине.

Каждый замер — 7 повторов, в таблицах стоит медиана.

Время работы

Медиана из 7 повторов, в секундах. «Добавка на вызов» — разница, поделённая на число вызовов.

язык без обмазки с обмазкой добавка добавка на вызов
Python 0,0202 с 1,1837 с 1,1635 с 58,2 мкс
JavaScript 0,0366 с 0,4454 с 0,4088 с 20,4 мкс
C 0,0017 с 0,3988 с 0,3972 с 19,9 мкс
C++ 0,0021 с 0,4469 с 0,4448 с 22,2 мкс
Elixir 0,7909 с 4,6060 с 3,8151 с 190,7 мкс
Go 0,0228 с 0,6013 с 0,5785 с 28,9 мкс
Java 0,0332 с 0,4518 с 0,4186 с 20,9 мкс
C# 0,0416 с 0,3535 с 0,3118 с 15,6 мкс
C, короткий вид (--minimal) 0,0017 с 0,1547 с 0,1530 с 7,7 мкс
Go, без номера горутины 0,0228 с 0,5501 с 0,5272 с 26,4 мкс

Все восемь строк сняты одним прогоном scripts/measure/run.sh, а не собраны из разных дней. Это важно: числа заметно гуляют от прогона к прогону (Python в прошлый раз дал 63,5 микросекунды, сейчас 58,2), и сравнивать между собой можно только строки одной таблицы.

Что здесь видно:

Дороже всего запись, а не язык. Четыре языка из восьми — JavaScript, C, C++ и Java — уложились в 20–22 микросекунды на вызов, хотя сами по себе они различаются в разы. У троих из них столько стоит открыть файл, дописать строку и закрыть его, дважды на вызов; Java файл открытым держит — и всё равно попадает в ту же полосу. Go рядом с ними, но не внутри полосы: 28,9 микросекунды.

C# дешевле всех — 15,6 микросекунды. Причина не в языке: его помощник, как и Java, держит файл открытым и дописывает в него, тогда как остальные шесть открывают и закрывают файл на каждую запись.

Python дороже втрое — 58 микросекунд. Обёртка там на самом Python, и каждая запись собирается через json.dumps и reprlib.

Go — 28,9 микросекунды, и 2,5 из них стоит поле th. Последняя строка таблицы — та же программа с тем же помощником, у которого поиск номера горутины заменён постоянным числом; разница двух замеров и есть цена этого поля. Остаток, 26,4 микросекунды, лежит примерно на четверть выше полосы 20–22 у JavaScript, C, C++ и Java — то есть вывод «дороже всего запись» на Go держится по порядку величины (Go вдвое дешевле Python и вшестеро дешевле Elixir), но в тесную полосу четырёх языков Go не попадает. Разбор поля thниже.

Elixir дороже всех — около 190 микросекунд на вызов. Часть этого — сам BEAM: без обмазки его запуск занимает 0,79 секунды, тогда как у остальных семи языков вся программа целиком укладывается в сотые доли.

У Elixir замер самый неустойчивый. Отдельные прогоны дали медианы от 4,11 до 4,81 секунды, то есть добавку на вызов от 161 до 202 микросекунд — разброс около четверти. Внутри одного прогона повторы тоже расходятся: от 4,05 до 5,02 секунды. Поэтому «около 190 микросекунд», а не «190,7»; верить тут можно порядку величины, но не третьему знаку. У остальных языков повторы сошлись в пределах нескольких процентов: Java 0,438–0,476 с, C# 0,351–0,364 с.

Короткий вид записи для C дешевле полного втрое — 7,7 микросекунды против 19,9. Он пишет одну строку вместо двух и не собирает кадр вызова. Чем за это платят — ниже, в разделе про C.

Считать отношение «во сколько раз медленнее» по этой таблице нельзя. Оно тут получается от 6 (Elixir) до 313 (C), и обе цифры ничего не говорят об инструменте: подопытная программа не делает ничего, кроме вызовов, поэтому отношение показывает лишь, насколько быстрым был пустой цикл на данном языке. Осмысленное число — это добавка на вызов, и она у семи языков из восьми лежит в пределах от 15,6 до 58,2 микросекунды. В настоящей программе, где функция считает что-то полезное, доля добавки меньше во столько раз, во сколько сама функция дороже этих микросекунд.

Объём записей

Тот же прогон, 20 002 вызова (20 003 у C++, Java и C#).

язык строк всего байт байт на запись байт на вызов
Python 40 004 5 050 377 126,2 252,5
JavaScript 40 004 4 730 756 118,3 236,5
C 40 004 4 968 026 124,2 248,4
C++ 40 006 5 388 341 134,7 269,4
Elixir 40 004 5 088 052 127,2 254,4
Go 40 004 4 868 011 121,7 243,4
Java 40 006 5 028 311 125,7 251,4
C# 40 006 5 028 310 125,7 251,4
C, короткий вид 20 002 760 078 38,0 38,0

Объём почти не гуляет между прогонами: у C, C++, Elixir и Go он повторяется побайтово, у Python и JavaScript расходится на сотые доли процента — там в запись попадает длительность, и 2e-06 короче, чем 0.000012.

Две строки на вызов — вход и выход — у всех, кроме короткого вида для C: там одна.

От 236,5 до 269,4 байта на вызов при коротких доводах (два целых числа). C++ дороже всех, потому что имена там пишутся полными: demo::add вместо add. Go — 243 байта, ближе к нижнему краю: длительность у него печатается с шестью знаками, как у C, а номер горутины короче номера потока. Java и C# разошлись на один байт за пять мегабайт — они пишут одно и то же. Длинные доводы поднимут это до предела в 4096 байт на запись, после чего значения начинают обрезаться.

Практический счёт: миллион вызовов — это примерно четверть гигабайта.

Python

Подопытный файл до обмазки:

"""Пример для замера: сложение, деление и накопление."""
import sys

N = int(sys.argv[1]) if len(sys.argv) > 1 else 20000


def add(a, b):
    return a + b


def div(a, b):
    return a / b


def main():
    total = 0
    for i in range(N):
        total = add(total, i)
    try:
        div(1, 0)
    except ZeroDivisionError:
        pass
    return total


print(main())

Обмазка:

ouroboros wrap-file add.py
{"ok": true, "path": "add.py", "language": "python", "functions_wrapped": 3, "runtime_header": "ouroboros_runtime.py"}

Результат — три надстройки @_ouro_log и одна строка ввоза. Строка ввоза встала ниже описания модуля, а не выше:

"""Пример для замера: сложение, деление и накопление."""
from ouroboros_runtime import log as _ouro_log
import sys

Это проверено не глазами, а запуском: файл до обмазки и файл после обмазки загружены в один и тот же процесс, и у обоих спрошено __doc__.

plain    __doc__ = 'Пример для замера: сложение, деление и накопление.'
wrapped  __doc__ = 'Пример для замера: сложение, деление и накопление.'

Рядом с обмазанным файлом появился ouroboros_runtime.py — помощник, из которого обмазанный код берёт надстройку.

Записи:

{"p":"in","t":"2026-08-29T08:30:14.227","id":"780b1d14-…","ci":-1,"th":"1175211.128643830919680","fn":"main","a":"","k":""}
{"p":"in","t":"2026-08-29T08:30:14.227","id":"7b0bd3b0-…","ci":-1,"th":"1175211.128643830919680","fn":"add","a":"0, 0","k":""}
{"p":"out","id":"7b0bd3b0-…","fn":"add","r":"0","d":2e-06}
{"p":"out","id":"e81a5517-…","fn":"div","x":"ZeroDivisionError: division by zero","d":5e-06}

Что записалось: имя функции, значения доводов по позиции, возвращённое значение, длительность, метка потока, время входа, номер вызова. Исключение записалось вместе с его видом и сообщением.

Чего не записалось: имена доводов (a содержит 0, 0, а не a=0, b=0) и номер ядра (ci всегда -1).

Чего стоило: 58,2 микросекунды и 252,5 байта на вызов.

JavaScript

Подопытная функция:

function add(a, b) {
  return a + b;
}

После обмазки:

function add(a, b) { const __ouro_ctx = _ouro_rt.enter("add", [a, b]); let __ouro_result, __ouro_threw = false; try {
  return (__ouro_result = (a + b));
 } catch (__ouro_e) { __ouro_threw = true; _ouro_rt.exit_throw(__ouro_ctx, __ouro_e); throw __ouro_e; } finally { if (!__ouro_threw) _ouro_rt.exit(__ouro_ctx, __ouro_result); }}

Тело не переписывается — вокруг него ставится try, а каждое return превращается в присваивание с последующим возвратом. Ранние выходы, несколько return и уже написанный try/finally продолжают работать.

Записи:

{"p":"in","t":"2026-08-29T08:30:58.319","id":"01ace5dd-…","ci":-1,"th":"1214487.0","fn":"add","a":"0, 0","k":""}
{"p":"out","id":"01ace5dd-…","fn":"add","r":"0","d":0.000046}
{"p":"out","id":"8ab042d5-…","fn":"div","x":"RangeError: деление на ноль","d":0.000113}

Что записалось: то же, что у Python. Кириллица в сообщении об ошибке сохранилась как есть, без бегства в \u.

Чего не записалось: имена доводов, номер ядра. Поле k всегда пустое — в JavaScript нет именованных доводов.

Метка потока1214487.0: номер процесса и номер рабочего потока. У главного потока он 0.

Чего стоило: 20,4 микросекунды и 236,5 байта на вызов — записи у него короче, чем у остальных семи, а по времени дешевле только C# и C.

C

Подопытный файл:

static long add(long a, long b)
{
	return a + b;
}

static const char *name(const char *who)
{
	return who;
}

После обмазки:

static long add(long a, long b)
{
	struct _ouro_call __ouro __attribute__((cleanup(_ouro_emit)));
	long __ouro_result;
	_ouro_enter(&__ouro, "add", "%ld, %ld", a, b);

	return  (__ouro_result = (a + b), _ouro_set_result(&__ouro, "%ld", __ouro_result), __ouro_result);
}

Вид довода подбирается по разобранному типу: %ld для long, %d для int, %p для любого указателя — включая const char *, который печатается адресом, а не содержимым (почему). Строка записи собирается при обмазке, когда типы известны.

Выход ловится через __attribute__((cleanup)) — расширение gcc и clang. Запись о выходе появится на любом пути: return, goto, конец тела.

Что C записать не может

Отдельный файл с разными видами возврата:

struct point { int x, y; };
static struct point make(int x, int y) {  }   /* возвращает структуру */
static void nothing(int n) {  }               /* ничего не возвращает */
static double half(double v) {  }
static unsigned char byte(unsigned char c) {  }

Записи:

{"p":"out","id":"83961f25-…","fn":"make","r":"(no value)","d":0.000000}
{"p":"out","id":"cdf9f985-…","fn":"nothing","r":"(no value)","d":0.000000}
{"p":"out","id":"eb1cc1ff-…","fn":"byte","r":"7","d":0.000000}
{"p":"out","id":"bf9b221d-…","fn":"half","r":"2.500000","d":0.000000}

Возвращённая структура не записывается — в поле r стоит (no value). У структуры нет вида для printf, а придумывать его инструмент не стал. Доводы, длительность и сам факт вызова записаны как обычно.

Функция, которая ничего не возвращает, тоже даёт (no value) — тут это просто правда.

У C нет исключений, поэтому поля x в его записях не бывает никогда — только r.

Длительность у C округлена до микросекунды (%ld.%06ld), поэтому вызовы короче микросекунды показывают 0.000000. В записях выше это видно: четыре вызова из пяти дали ровно ноль, ненулевым оказался только main. Для отбора медленных вызовов это не мешает, для измерения быстрых — мешает совсем.

Короткий вид записи

--minimal — отдельный, облегчённый вид, рассчитанный на горячие и рекурсивные функции и на сборку внутри ядра операционной системы:

ouroboros wrap-file minimal.c --minimal
static long add(long a, long b)
{
	char __ouro __attribute__((cleanup(_ouro_min_exit))) = _ouro_min_enter("add");

	return a + b;
}

Кадра вызова нет, есть один байт-отметка. Записи:

{"p":"in","dep":0,"ci":-1,"fn":"main"}
{"p":"in","dep":1,"ci":-1,"fn":"add"}
{"p":"in","dep":1,"ci":-1,"fn":"add"}

Здесь нет ни доводов, ни возвращённого значения, ни номера вызова, ни времени, ни длительности — и нет строки выхода. Есть имя и глубина вложенности (dep).

Из этого следует важное: читалка записей такие строки не считает вызовами. Каждая строка входа без пары становится «незавершённым вызовом». На 20 002 вызовах ouroboros trace-stats отвечает:

{
  "calls_parsed": 0,
  "total_calls": 0,
  "in_flight": [ { "name": "main", "call_id": "", "started": "",  },  ]
}

Двадцать тысяч записей в списке незавершённых и ни одного разобранного вызова. Короткий вид годится, чтобы увидеть дерево вызовов — кто кого позвал и на какую глубину, — но обычные средства чтения записей к нему не применимы.

Что он даёт взамен: 7,7 микросекунды вместо 19,9 и 38 байт на вызов вместо 248.

Короткий вид есть только у C. На остальных языках он отвечает отказом:

{"ok": false, "error": "minimal probe mode is C-only (kernel ring sink)", "language": "python"}

C++

Подопытный файл с пространством имён и возвратом объекта:

namespace demo {
struct Point { int x, y; };
long add(long a, long b) { return a + b; }
Point make(int x, int y) { return Point{x, y}; }
double div(double a, double b) {  }
}

После обмазки:

long add(long a, long b)
{
	std::ostringstream __ouro_args; __ouro_args << _ouro::repr(a) << ", " << _ouro::repr(b);
	_ouro::Scope __ouro("demo::add", __ouro_args.str());
	try {

	return _ouro::capture(__ouro, (a + b));

	} catch (...) { __ouro.note(); throw; }
}

Выход ловится сторожем области видимости — объектом, разрушитель которого пишет строку выхода. Отдельно стоит catch (...), который записывает вид исключения и тут же бросает его дальше: во время раскрутки стека разрушитель не может узнать, какое исключение летит, а catch может.

Имя пишется полнымdemo::add, а не add. Отсюда и лишние байты: 269 на вызов против 248 у C.

Что C++ записать не может

Функция make возвращает Point — объект классового типа по значению. В обмазанном коде вокруг её return ничего не появилось:

Point make(int x, int y)
{
	
	return Point{x, y};      /* без _ouro::capture */
	
}

Запись:

{"p":"out","id":"185855d5-…","fn":"demo::make","r":"(no value)","d":0.000000}

Это осознанный выбор, а не недоделка. Провести такой возврат через помощник означало бы отменить пропуск копирования, который C++17 гарантирует: программа, считающая свои конструкторы, начала бы печатать перемещение, которого без обмазки не было, а тип с удалёнными копированием и перемещением просто перестал бы собираться. Инструмент, меняющий поведение измеряемого, бесполезен — поэтому наблюдаемость здесь проиграла прозрачности намеренно.

Числа, указатели и ссылки записываются полностью. Доводы, длительность и факт исключения — всегда.

Исключение записывается вместе с видом:

{"p":"out","id":"a3bfa016-…","fn":"demo::div","x":"std::runtime_error: деление на ноль","d":0.000077}

Elixir

Подопытный модуль:

defmodule Zamer do
  def add(a, b), do: a + b

  def ratio(_a, 0), do: raise(ArithmeticError, "деление на ноль")
  def ratio(a, b), do: a / b

  def run(n) do
    
  end
end

Обмазка вставляет одну строку на модуль:

defmodule Zamer do
  use Ouroboros.Trace
  def add(a, b), do: a + b
{"ok": true, "path": "add.ex", "language": "elixir", "functions_wrapped": 1, "runtime_header": "ouroboros_trace.ex"}

functions_wrapped: 1 здесь — это один модуль, а не одна функция. У Elixir обмазка идёт по модулям целиком: use Ouroboros.Trace переопределяет def и defp, и все функции модуля оказываются обмазаны разом. На остальных семи языках это число означает функции. Не сравнивайте его между языками.

Из того же следует, что выбрать отдельные функции у Elixir нельзя:

ouroboros wrap-functions add.ex add
{"ok": false, "error": "[elixir] corrupted source in a.ex: selective `only=` is unsupported for the Elixir (module-granular) backend", "language": "elixir"}

Порядок сборки важен. use Ouroboros.Trace разворачивается во время сборки, поэтому помощник ouroboros_trace.ex должен быть собран раньше любого модуля, который его использует:

elixirc -o ebin ouroboros_trace.ex add.ex

Записи:

{"p":"in","t":"2026-08-29T08:33:21.133","id":"5061aa72-…","ci":-1,"th":"1293945.#PID<0.95.0>","fn":"add","a":"0, 0","k":""}
{"p":"out","id":"5061aa72-…","fn":"add","r":"0","d":0.000003}
{"p":"out","id":"a735ea12-…","fn":"ratio","x":"ArithmeticError: деление на ноль","d":0.021161}

Метка потока1293945.#PID<0.95.0>: номер процесса операционной системы и номер процесса BEAM. Обе половины нужны: по номеру процесса ОС два процесса BEAM не различить, а по номеру процесса BEAM не различить два узла, пишущих в один файл.

Имя функции пишется короткимadd, а не Zamer.add. Имени модуля в записи нет.

ratio обмазана в обеих ветвях — и та, что бросает, и та, что делит. Каждая ветвь обмазывается отдельно, охранные выражения и значения по умолчанию проходят насквозь.

Go

Подопытный файл до обмазки:

// Пример для замера: сложение и функция, которая паникует.
package main

import (
	"fmt"
	"os"
	"strconv"
)

func add(a, b int) int { return a + b }

func boom() int { panic("так задумано") }

func main() {
	n := 20000
	if len(os.Args) > 1 {
		if v, err := strconv.Atoi(os.Args[1]); err == nil {
			n = v
		}
	}
	total := 0
	for i := 0; i < n; i++ {
		total = add(total, i)
	}
	func() {
		defer func() { _ = recover() }()
		boom()
	}()
	fmt.Println("итог", total)
}

Обмазка:

ouroboros wrap-file add.go
{"ok": true, "path": "add.go", "language": "go", "functions_wrapped": 3, "runtime_header": "ouroboros_runtime.go"}

Во что превращается add:

func add(a, b int) (__ouro_r0 int) {
	__ouro_ctx := _ouroEnter("add", a, b)
	defer func() {
		if __ouro_p := recover(); __ouro_p != nil {
			_ouroPanicked(__ouro_ctx, __ouro_p)
			panic(__ouro_p)
		}
		_ouroReturned(__ouro_ctx, __ouro_r0)
	}()
 return a + b }

Возврат не переписан. Вместо этого возвращаемому значению дано имя __ouro_r0, и замыкание читает его после того, как return его присвоил. Тип подписи от этого не меняется, поэтому вызывающие, интерфейсы и функции-значения работают как прежде.

Строки ввоза нет. Помощник — файл того же пакета: Go не умеет ввозить соседний файл, а значит, над заголовком ничего вставлять и не нужно. Собирать надо оба файла:

go build -o add add.go ouroboros_runtime.go

Записи:

{"p":"in","t":"2026-08-29T23:50:53.477","id":"3bf958d0-…","ci":-1,"th":"2388843.1","fn":"main","a":"","k":""}
{"p":"in","t":"2026-08-29T23:50:53.477","id":"432672b6-…","ci":-1,"th":"2388843.1","fn":"add","a":"0, 0","k":""}
{"p":"out","id":"432672b6-…","fn":"add","r":"0","d":0.000000}
{"p":"out","id":"0aa87493-…","fn":"boom","x":"string: так задумано","d":0.000006}

Паника записана видом и текстомstring: так задумано. Своего рода исключений у Go нет, поэтому вид берётся от самого значения паники: panic("…") паникует строкой, а выход за границу среза дал бы runtime.boundsError.

Метка потока2388843.1: номер процесса операционной системы и номер горутины.

Во что обходится номер горутины

Узнать номер горутины в Go можно единственным способом: разобрать первую строку собственного следа вызовов, который печатает runtime.Stack. Своего вызова для этого исполняющая среда не даёт, а переносимого способа спросить номер потока операционной системы нет тем более — syscall.Gettid есть только под Linux.

Беда в том, что runtime.Stack обходит весь стек, каким бы маленьким ни был буфер. Снято прогоном (goroutine_id.go из образцов, 30 000 повторов на каждую глубину):

глубина стека мкс на один вызов runtime.Stack
0 2,655
10 9,554
30 21,649
100 67,220
300 87,562

То есть цена поля th растёт вместе с глубиной стека программы. В замере выше, где add вызывается из main, она вышла 2,5 микросекунды на вызов (28,9 против 26,4 у той же программы с помощником, где номер горутины заменён постоянным числом). В глубоко рекурсивной программе она будет заметно больше.

Платится это ровно один раз на вызов, на строке входа, и до того, как запускаются часы длительности, — поэтому в поле d она не попадает.

Java

Подопытный класс:

public class Add {
	static long add(long a, long b)
	{
		return a + b;
	}
	
}

Обмазка вставляет try/catch/finally в тело и заворачивает возврат во временную своего типа:

	static long add(long a, long b)
	{ ouroboros.OuroborosRuntime.Ctx __ouro_ctx = ouroboros.OuroborosRuntime.enter("Add.add", new java.lang.Object[]{a, b}); long __ouro_result = 0L; try {
		return (__ouro_result = a + b);
	 } catch (java.lang.Throwable __ouro_e) { ouroboros.OuroborosRuntime.exitThrow(__ouro_ctx, __ouro_e); throw __ouro_e; } finally { ouroboros.OuroborosRuntime.exit(__ouro_ctx, __ouro_result); }}

Записи:

{"p":"in","t":"2026-08-30T02:20:32.438","id":"333229db-…","ci":-1,"th":"750478.3","fn":"Add.run","a":"20000","k":""}
{"p":"out","id":"99258afb-…","fn":"Add.div","x":"java.lang.ArithmeticException: деление на ноль","d":0.003388}

Ничего не вставляется в заголовок файла. Помощник зовётся полным именем — ouroboros.OuroborosRuntime, — поэтому обмазка ни разу не решает, куда можно поставить строку ввоза. У JavaScript ровно на эту одну строку ушло три отдельных правила (ниже #!, ниже собственных указаний файла, import или require), и каждое из них сперва было ошибкой. В Java этот вопрос можно не задавать, и он не задаётся; цена — более длинное место вызова.

Ни одна врезка не содержит перевода строки. Номера строк в обмазанном файле те же, что в исходном, поэтому след вызовов у необработанного исключения совпадает построчно. В наборе равенства это проверяется прямо: программа ловит своё исключение и печатает имя_метода:номер_строки для каждого своего кадра.

Временная своего типа, а не обобщённый помощник. Сначала было проще: return ouroboros.OuroborosRuntime.ret(__ouro_ctx, выражение). На методе char asChar() { return 65; } это перестало собираться:

inferred type does not conform to upper bound(s)
    inferred: Integer
    upper bound(s): Character,Object

Обобщённый помощник выводит свой тип из довода, а не из метода, и 65 становится Integer, который в char уже не сузить. Временная переменная, объявленная тем самым типом, что написан у метода, возвращает выражение в присваивание с нужным типом — там сужение постоянной снова законно. Заодно у выражений с целевым типом (лямбды, switch, тернарник) сохраняется их исходная цель.

Голый return; не трогается вовсе. Строку выхода пишет самый внешний finally, каким бы способом тело ни ушло, поэтому у методов void и у конструкторов правки возвратов нет ни одной.

C#

Подопытный класс — тот же счёт, обмазка той же формы:

	static long Sum(long a, long b)
	{ Ouroboros.OuroborosRuntime.Ctx __ouro_ctx = Ouroboros.OuroborosRuntime.Enter("Add.Sum", new object[]{a, b}); try {
		return Ouroboros.OuroborosRuntime.Ret<long>(__ouro_ctx, a + b);
	 } catch (System.Exception __ouro_e) { Ouroboros.OuroborosRuntime.ExitThrow(__ouro_ctx, __ouro_e); throw; } finally { Ouroboros.OuroborosRuntime.ExitPending(__ouro_ctx); }}

Записи:

{"p":"in","t":"2026-08-30T02:20:39.785","id":"38c8b15d-…","ci":-1,"th":"751111.1","fn":"Add.Run","a":"20000","k":""}
{"p":"out","id":"c52bf2df-…","fn":"Add.Div","x":"System.DivideByZeroException: деление на ноль","d":0.000544}

Тип у Ret<long> написан, а не выведен. По той же причине, что и временная в Java: без него return null; и return x => x + 1; дают

The type arguments for method 'OuroborosRuntime.Ret<T>(OuroborosRuntime.Ctx, T)'
cannot be inferred from the usage.

Тип берётся тот, что написан у самой части (у async — раскрытый из Task<T>/ValueTask<T>, потому что return несёт именно T), и подставляется дословно, в том же файле и том же пространстве имён, так что разрешается ровно как раньше.

throw; без довода. Перебросить пойманное (throw __ouro_e;) значит сбросить у исключения место броска на строку обёртки, и наблюдаемая программа напечатала бы другой след только оттого, что за ней наблюдают. Голый throw; этого не делает: у необработанного исключения след совпадает построчно.

Не всё можно обмазать, и об этом говорится вслух. Пять видов частей оставляются нетронутыми, и каждый — потому что обмазанный вариант не собирается:

что что скажет компилятор
часть с yield CS1626: Cannot yield a value in the body of a try block with a catch clause
возврат по ссылке (ref int M()) CS8150: By-value returns may only be used in methods that return by value
указатель в доводе или возврате CS0306: The type 'int*' may not be used as a type argument
ссылочная структура (Span<int>) CS9244: The type 'ReadOnlySpan<char>' may not be a ref struct …
свойство с телом-выражением (int P => x;) собралось бы, но развернуть его — значит переписать устройство части, а не врезаться в тело

Про каждую такую часть обмазка возвращает предупреждение с причиной, а не молчит:

Program.UseView: left alone (ref-struct-parameter)

Довод out — единственное, что выпадает не целиком: часть обмазывается, но сам довод в снимок на входе не попадает, потому что на входе он ещё не присвоен (CS0269).

Цена разборщика у Java и C#

У Python разборщик в поставке языка, у Go тоже; для JavaScript в пакет уложен @babel/parser, для C и C++ — libclang. У Java и C# разборщик тоже ничего не стоит достать, но по-разному, и цена у них разная.

  Java C#
откуда разборщик javax.tools + com.sun.source — внутри самого JDK Roslyn — внутри пакета .NET SDK
скачивается ли что-нибудь нет нет
сборка помощника один раз на машину 0,73 с 1,87 с
сколько занимает собранное 15 561 байт, 5 файлов 32,3 МБ, 35 файлов
один разбор файла в 202 строки медиана 322,8 мс (20 прогонов, 308–359) медиана 89,6 мс (20 прогонов, 85–95)

Java: почти нечего хранить, но каждый разбор дорог. Пятнадцать килобайт — это сам помощник и больше ничего: разборщик уже лежит в JDK. Зато на каждый разбор заводится своя виртуальная машина Java, и 323 миллисекунды — это в основном её запуск, а не разбор.

C#: наоборот. Тридцать два мегабайта — это две библиотеки Roslyn, скопированные рядом с помощником; сам помощник в них — 0,02 МБ. Зато разбор втрое с половиной быстрее.

Ни в одном из двух случаев ничего не тянется из сети: и JDK, и .NET SDK нужны на машине в любом случае, чтобы собрать обмазанный код. Путь к Roslyn в дереве не записан — он выясняется из того, что печатает dotnet --list-sdks, и целевая среда тоже выводится из установленного SDK, а не прибита к net10.0.

Глубина рекурсии в Python

Обмазка Python — надстройка, то есть между вызывающим и вызываемым встаёт обёртка, и каждый вызов занимает два кадра стека вместо одного.

Подопытная функция считает, до какой глубины дошла:

reached = 0


def descend(n):
    global reached
    if n > reached:
        reached = n
    return descend(n + 1)

Запуск с разными пределами:

deep_plain: предел рекурсии 200, наибольшая достигнутая глубина 199
deep:       предел рекурсии 200, наибольшая достигнутая глубина 95
deep_plain: предел рекурсии 1000, наибольшая достигнутая глубина 999
deep:       предел рекурсии 1000, наибольшая достигнутая глубина 495

Примерно вдвое мельче. Программа с тесно подогнанным пределом рекурсии может упереться в него после обмазки там, где не упиралась до. Пока механизм — надстройка, это неустранимо. Для глубокой рекурсии обмазывайте не саму рекурсивную функцию, а тех, кто её зовёт.

Строгий режим JavaScript

Директива "use strict" действует, только пока она первая. Строка ввоза помощника, поставленная выше неё, молча отключила бы строгий режим — программа продолжала бы работать, но иначе.

Подопытная программа присваивает необъявленную переменную: в строгом режиме это ReferenceError, без него — молчаливое создание глобальной переменной.

до обмазки:    БРОСИЛО: ReferenceError
после обмазки: БРОСИЛО: ReferenceError

Обмазанный файл:

"use strict";
const _ouro_rt = require("./ouroboros_runtime.js");

Ввоз встал ниже директивы. Режим сохранён.

Возврат списком в скобках

return {1, 2, 3}; в C++ раньше превращался в return _ouro::capture(__ouro, ({1, 2, 3}));, чего компилятор не принимает: список в фигурных скобках, обёрнутый в круглые, — не выражение.

Сейчас такой возврат оставляется как есть:

std::vector<int> three()
{
	_ouro::Scope __ouro("three", "");
	try {

	return {1, 2, 3};

	} catch (...) { __ouro.note(); throw; }
}
СОБРАЛОСЬ
3

Ценой того, что значение не записывается:

{"p":"out","id":"8d43618d-…","fn":"three","r":"(no value)","d":0.000001}

Помогает ли запись понять чужой код

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

Опыт лежит в scripts/measure/trace-help/, переснимается одной командой и описан там же в README.md — с полным выводом всех прогонов, устройством и мерой, объявленной до первого прогона. Здесь — короткий пересказ.

Как устроено

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

Шестьдесят вопросов, каждый задан дважды:

группа что даётся отвечающему
контрольная исходник целиком, точная команда запуска с доводами, вывод программы
опытная то же самое плюс debug.info того же запуска

Контрольная группа получает всё, чем можно вывести ответ самому. Правильные ответы записаны руками при сочинении программ и сверены с прогоном: build.py достаёт те же величины из записи и падает при расхождении. Считает ответы grade.py, которой на вход идут только номер вопроса и текст ответа — была ли запись, она не знает.

Разброс: 95-процентный промежуток пересборкой по программам (общепринятое название приёма — bootstrap). Порог объявлен заранее: разница считается находкой, только если ноль в промежуток не попадает.

Кто отвечал

кто отвечал ответов верных без записи верных с записью разница промежуток
qwen3.5:4b, 4,7 млрд весов 600 44,0 % 78,3 % +34,3 19,7 … 47,7
qwen2.5:14b-instruct, 14,8 млрд 600 61,0 % 84,7 % +23,7 12,3 … 35,0
qwen3:32b, 32,8 млрд 600 66,7 % 90,3 % +23,6 10,3 … 36,3
подчинённый агент Claude Opus 5 120 95,0 % 98,3 % +3,3 0,0 … 8,3

Три модели под ollama снимались на машине с RTX 6000 Ada, температура 0,8, зерно — номер повтора, одинаковое в обеих группах, внутреннее рассуждение выключено у всех трёх. Пять повторов; между повторами доля верных гуляет на 1,4–3,6 пункта, то есть заметно меньше разницы.

На сильной модели прибавка не показана. У агентов ноль попадает в промежуток — по объявленному заранее порогу это не находка. Устроено так: каждый вопрос отдаётся отдельному агенту, который не знает ни про опыт, ни про то, что групп две, ни про то, в какой он группе. Из 120 ответов неверных четыре, и все четыре — «нет» вместо false: верное значение в неверном виде. На этих двенадцати программах сильная модель знает ответ и без записи. У агентов один повтор, а не пять, поэтому промежуток шире; «прибавки нет» здесь значит «прибавка не показана».

Где прибавки нет

Числа qwen2.5:14b-instruct:

о чём вопрос ответов на группу без записи с записью
что вернул такой-то вызов 140 46,4 % 87,1 %
с чем позвали функцию 10 50,0 % 100,0 %
сколько раз вызвана функция 90 58,9 % 68,9 %
вызывалась ли функция вообще 55 100,0 % 100,0 %

Последняя строка — ноль, и он повторился на трёх отвечающих из четырёх. Вся прибавка сидит на вопросах о значениях: запись отвечает на «что было» и почти ничего не добавляет к «что написано».

По языкам (qwen2.5:14b-instruct / qwen3:32b, по 50 ответов на язык на модель):

язык без записи с записью большая модель, без большая модель, с
Python 50,0 % 100,0 % 46,0 % 94,0 %
JavaScript 52,0 % 96,0 % 50,0 % 94,0 %
C 52,0 % 70,0 % 74,0 % 78,0 %
C++ 58,0 % 74,0 % 60,0 % 80,0 %
Elixir 70,0 % 84,0 % 74,0 % 100,0 %
Go 84,0 % 84,0 % 96,0 % 96,0 %

У Go прибавки нет вовсе — на обеих моделях. Пятьдесят ответов на язык — это указание, а не вывод: у C++ на одном прогоне вышло 58,0 → 74,0 %, а на другом тем же вечером 58,0 → 64,0 %.

Когда запись не влезает в запрос

Всё выше — про короткие программы, где запись помещается целиком. Отдельный прогон проверяет обратный случай: четыре из тех же двенадцати программ запускаются на входе в сотни раз большем. Запись выходит около 1,62 миллиона знаков против 4 670 знаков исходника — в 347 раз больше, и в запрос кладётся обрезок в 8 000 знаков: начало и конец, середина вырезана, на её месте строка «здесь вырезано N строк».

Тридцать шесть вопросов, 360 ответов, qwen2.5:14b-instruct: верных 13,9 % без записи против 37,2 % с обрезком, промежуток 15,0 … 30,6. Полезен ровно уцелевший кусок:

где лежит ответ ответов на группу без записи с обрезком
нужный вызов уцелел 55 9,1 % 65,5 %
нужный вызов попал в вырезанную середину 65 0,0 % 10,8 %
«сколько раз вызвана» — нужна вся запись 40 0,0 % 10,0 %
«вызывалась ли вообще» — функции в записи нет 20 100,0 % 100,0 %

Разметка «уцелел / вырезан» считается не на глаз: long.py знает, какие именно строки выброшены, и смотрит, попали ли в них вход и выход нужного вызова.

Практический вывод: отдавать запись целиком стоит, пока она длиннее исходника в разы. Когда в сотни раз — обмазывать надо не файл целиком, а названные функции (ouroboros wrap-functions), и не обрезать запись по краям.

Цена в знаках

На коротких программах: 13 070 знаков исходника против 48 777 знаков записей — в 3,73 раза больше (от 2,45 до 6,16 у разных программ, посередине 3,52). Запрос к модели растёт с 1 623 знаков до 5 904, втрое с половиной.

Чего здесь не мерялось

Честный список того, что осталось за пределами этой страницы. Каждый пункт — место, где утверждать что-либо было бы выдумкой.