Записывает, как код на самом деле исполнялся: вызовы, доводы, результаты, исключения, длительности
Здесь лежат числа, снятые прогонами, и всё, что нужно, чтобы эти прогоны повторить: исходники подопытных программ, команды и вывод как есть.
Ни одно число на этой странице не переписано из другого места. Если вы запустите то же самое у себя, вы получите свои числа — они будут другими, потому что машина другая, — но соотношения между языками должны сохраниться.
Одна и та же работа на всех восьми языках: функция 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 байт на запись, после чего
значения начинают обрезаться.
Практический счёт: миллион вызовов — это примерно четверть гигабайта.
Подопытный файл до обмазки:
"""Пример для замера: сложение, деление и накопление."""
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 байта на вызов.
Подопытная функция:
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.
Подопытный файл:
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, конец тела.
Отдельный файл с разными видами возврата:
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"}
Подопытный файл с пространством имён и возвратом объекта:
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.
Функция 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}
Подопытный модуль:
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 обмазана в обеих ветвях — и та, что бросает, и та, что делит.
Каждая ветвь обмазывается отдельно, охранные выражения и значения по умолчанию
проходят насквозь.
Подопытный файл до обмазки:
// Пример для замера: сложение и функция, которая паникует.
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 она не попадает.
Подопытный класс:
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 и у
конструкторов правки возвратов нет ни одной.
Подопытный класс — тот же счёт, обмазка той же формы:
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).
У 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 — надстройка, то есть между вызывающим и вызываемым встаёт обёртка, и каждый вызов занимает два кадра стека вместо одного.
Подопытная функция считает, до какой глубины дошла:
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
Примерно вдвое мельче. Программа с тесно подогнанным пределом рекурсии может упереться в него после обмазки там, где не упиралась до. Пока механизм — надстройка, это неустранимо. Для глубокой рекурсии обмазывайте не саму рекурсивную функцию, а тех, кто её зовёт.
Директива "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, втрое с половиной.
Честный список того, что осталось за пределами этой страницы. Каждый пункт — место, где утверждать что-либо было бы выдумкой.
printf(9) и
getnanouptime(9), — и именно там ci становится настоящим номером ядра. Эта
ветка здесь не собиралась и не запускалась. Всё, что о ней сказано в
документации, прочитано в исходнике..ts и .tsx.th у Go на глубоком стеке. Кривая выше снята отдельной
программой, а не через помощник записи: в замере с помощником стек всегда был
мелким (add вызывается из main). Сколько стоит вызов на глубине сотни
кадров в настоящей рекурсивной программе, здесь не мерялось.ouroboros wrap-functions в опыте не участвует,
хотя вывод указывает именно на него.