luazig — проект по переписыванию Lua на Zig с постоянной проверкой поведения против PUC Lua 5.5.0.
Цель не в том, чтобы написать похожий язык, а в том, чтобы постепенно прийти к drop-in совместимости с PUC Lua: тот же observable behavior на официальном test suite, честные ограничения, понятная архитектура и публичный Zig-facing API для embedding.
- Реализовать Lua 5.5.0 на Zig с поведением, максимально близким к PUC Lua.
- Проходить официальный upstream
testes/*.luaбез test-specific hacks и harness-обходов. - Держать reference implementation рядом и сравнивать
refvszigнапрямую. - Развивать публичный Zig embedding API, семантически близкий к Lua C API.
- Использовать актуальный system Zig как основной toolchain.
- При выборе архитектуры следовать PUC-first подходу, если он не ведёт к заведомо худшему решению.
Коротко: проект находится в pre-release / parity-focused состоянии.
Bytecode VM (--vm=bc) — единственный активно развиваемый backend (default).
IR VM (--vm=ir) заморожена: код компилируется и доступен для отладки, parity не поддерживается.
bc_vm проходит 29/29 test suites: api, attrib, bitwise, bwcoercion, calls, closure, code, constructs, coroutine, cstack, db, errors, events, files, gc, gengc, goto, literals, locals, math, memerr, nextvar, pm, sort, strings, tpack, tracegc, utf8, vararg. Матрица запускается с upstream portable/soft prelude _port=true; _soft=true; тяжёлые ветки constructs.lua и verybig.lua дополнительно проверяются напрямую без _soft.
IR VM (frozen snapshot) проходила 32/33 suites. Результаты сохранены как reference.
Ограничения:
- bc_vm достиг функциональной parity (29/29 suites), но производительность пока существенно ниже PUC Lua. Свежий ReleaseFast baseline показывает геометрическое среднее отставание 3.47× на representative-наборе; calls/hash/array отстают сильнее всего. Подробный профиль и поэтапный roadmap приведены в разделе «Производительность относительно PUC Lua 5.5».
- IR VM доступна через
--vm=irдля отладки, но не гарантируется от регрессий. - C ABI shim остаётся smoke/compat слоем поверх Zig API.
- Production/drop-in статус пока не заявляется.
Первоначальный host-recursive путь полностью устранён для активного bytecode
backend. Как и luaV_execute/CallInfo в PUC Lua, один dispatch driver
переключает heap-resident активации Lua, не сохраняя по Zig stack frame на
каждый Lua-вызов.
Что закрыто:
Thread.bytecode_framesхранит authoritativeBytecodeExecFrame, а register/boxed stack, runtime frames и TBC list принадлежат тому жеThread. Переключение coroutine move'ит ownership этих буферов без копии.BytecodePendingCompletionописывает post-call действия для обычных результатов, одиночных значений, сравнений,__concat,string.gsubreplacement callbacks, debug hooks,__closeи coroutine resume. Поэтому opcode или C-library bridge не требует вложенногоrunBytecodeи не переигрывается после yield.- Через общий explicit stack выполняются обычные
CALL/TAILCALL,TFORCALL,pcall/xpcall, Lua metamethods (__index,__newindex, арифметика, сравнения,__len,__concat,__pairs), Lua debug hooks,string.gsubreplacement/__indexcallbacks и yielding__close. - Error unwind хранится как
BytecodeUnwindStateна coroutine. Yield или новая ошибка внутри__closeпродолжает тот же LIFO scan. Уникальныйactivation_idотличает заменённый child frame от нового frame с повторно использованным stack base. - Вложенный
coroutine.resumeиспользует один trampoline и приватный execution-layer сигналThreadSwitch: caller остаётся припаркованным в собственном explicit stack, а глубина цепочки coroutine больше не равна глубине Zig вызовов. Сигнал поглощается внутри trampoline и не входит в публичныйVm.Error/Zig API. Попытка resume предка остаётся обычной Lua-ошибкой, а не циклом trampoline. - Bytecode-активации при yield/resume больше не сериализуются и не
replay'ятся: authoritative
BytecodeExecFrameостаётся наThread.IrSuspendedFrameсохранён только для замороженного IR compatibility-кода (включаяtestCclosures/hooks, вызванные из bc VM); такой IR child связан с bytecode caller через pending continuation, но сам bytecode frame snapshot не создаёт. - Обёртка
std.Thread.spawn(.{ .stack_size = 256MB })удалена. CLI работает на обычном process stack. Bytecode ограничивается собственными Lua-side лимитами frame/stack storage, а не native stack depth.
Постоянные регрессии:
tests/smoke/27_iterative_bytecode_calls.lua;tests/smoke/38_iterative_protected_dispatch.lua;tests/smoke/39_complete_iterative_dispatch.lua;tests/smoke/40_large_setlist_extraarg.lua;tests/smoke/41_strict_globals_env.lua;tests/smoke/42_gc_debt_and_generational_step.lua;tests/smoke/43_generational_minor.lua;tests/stress/iterative_dispatch.luaчерезtools/iterative_dispatch_stress.sh.
Проверенный stress lane под ulimit -s 1024: 5000 обычных Lua-вызовов, 3000
вложенных coroutine.resume с yield/resume, 2000 рекурсивных metamethod
вызовов, 1000 вложенных string.gsub callbacks и mixed bytecode↔testC yield
для CALL/TAILCALL/TFORCALL. Полный cstack.lua также проходит при
1-МБ host stack. Zig unit tests и 44/44 differential smoke проходят. Все
заявленные 29 upstream suites завершаются успешно на bc VM; точные глубины
C stack overflow остаются implementation-defined, но semantic assertions
совпадают.
Предыдущий TODO исходил из неверного предположения, что PUC Lua всегда эммитит
OP_TAILCALL для return f() с живым TBC slot. В Lua 5.5 retstat делает
tail-call только при !fs->bl->insidetbc; при активной <close> переменной
остаётся обычный CALL + RETURN, чтобы caller пережил callee и затем закрыл
TBC chain. Бит k у OP_TAILCALL в luaK_finish относится к закрытию open
upvalues (needclose), а не разрешает уничтожить frame с живым TBC slot.
codegen_bc.zig:hasActiveClose() теперь учитывает и именованные <close>
locals, и скрытый четвёртый TBC slot generic-for. Поэтому return f() внутри
обоих видов scope сохраняет caller до завершения f, а затем выполняет close
ровно один раз. 39_complete_iterative_dispatch.lua проверяет порядок для
обычного <close> local и generic-for iterator close value. Runtime
BytecodeClosePost.retry_tailcall остаётся для tailcall, где закрываются open
upvalues/остаточное runtime close-state, включая yield-safe продолжение.
Повторно проверен blocker-кейс с ранним return из generic for: hidden TBC
iterator close value закрывается после вычисления return-expression. PUC Lua
и bc VM печатают false true, а differential smoke
39_complete_iterative_dispatch.lua проходит без расхождений.
Внутренний coroutine control-flow отделён от публичной модели ошибок:
ThreadSwitch находится только в private DispatchError, поглощается
driveBytecodeCoroutineTrampoline, отсутствует в Vm.Error, а api.zig
больше не содержит специальной ветки для него. Рядом с
BytecodePendingCompletion зафиксированы invariants владения payload slices,
cleanup authority, yieldable continuation kinds и единственная разрешённая
точка thread switching.
После hardening и последующей regression-cleanup фазы повторно пройдены Zig unit tests, 42/42 differential smoke, 1-МБ iterative-dispatch stress lane и ровно заявленные 29/29 upstream suites по exit/assertion status.
WIP-переход на полный iterative dispatch временно открыл несколько независимых расхождений parser/codegen/debug runtime. Они исправлены на семантическом уровне, без распознавания имён upstream-файлов или test-specific replay:
- Bytecode codegen корректно эмитит большие
SETLIST + EXTRAARG, поэтомуverybig.luaпроходит реальную веткуtesting large programs (>64k). Быстрый differential regression закреплён вtests/smoke/40_large_setlist_extraarg.lua. - Восстановлены lexical invariants для
goto/labels, named vararg и Lua 5.5 global attributes; устранены overflow/diagnostic-buffer ошибки на больших constant/register indices и syntax-error paths. - Source line metadata теперь хранит call/operator lines в AST и размечает
loop backedges, многострочные expressions, synthetic register cleanup и
VARARGPREPтак же, как PUC-visible line table. Line hooks используют реальный previous-PC/previous-line state вместо удалённого preseed, завязанного на ожидаемый trace. - Debug-hook continuation явно владеет
saved_parent_calleeкак GC root. Раньше full GC внутри hook мог освободить closure активного parent frame, а завершение hook возвращало dangling pointer вRuntimeFrame.callee. Threads теперь sweep'ятся раньше closures, чтобы parked frames не сохраняли даже промежуточные ссылки на уже освобождённые closure storage. loadинициализирует environment первого upvalue main closure независимо от наличия имени_ENV. Это сохраняет исполняемость stripped chunks, где debug names удалены, но bytecode/upvalue layout остаётся прежним.tests/smoke/31_debug_bytecode_parity.luaрасширен двумя регрессиями: GC внутри line hook сохраняет parent activation, а stripped main chunk получает рабочий environment.
Подтверждённые проверки для Zig 0.16.0:
zig build test -Doptimize=Debug— PASS;- differential smoke — 42/42 PASS;
- iterative dispatch stress под 1-МБ host stack — PASS;
- заявленная upstream matrix с
_port=true; _soft=trueи timeout 60 секунд на suite — 29/29 PASS; - direct
constructs.luaбез_softподtimeout 60—OK(56.15 с в verification-контейнере); - direct
verybig.luaбез_soft—+,OK.
После review P15.27 закрыты все замечания, мешавшие воспроизводимому принятию патча:
- Автоматический GC больше не запускает полный mark+sweep по фиксированному
числу инструкций. Следующий цикл планируется от live-heap/debt после
предыдущего, поэтому тысячи динамических
load()не вызывают повторный полный scan растущего heap. Вместе с устранением лишних AST-аллокаций это довело неизменённый directconstructs.luaдо 56.15 с в Debug и позволило пройти жёсткийtimeout 60. - Короткие interned strings теперь учитываются в
gc_count_kb. Раньше цикл, создающий только короткие строки, мог выделить сотни мегабайт, не увеличив automatic-GC debt; upstreamclosure.luaбесконечно ждал очистки weak table. Регрессия закреплена вtests/smoke/42_gc_debt_and_generational_step.lua. - В generational mode каждый явный
collectgarbage("step")выполняет один атомарный цикл и возвращаетfalse, как PUC в наблюдаемом API. Это сохраняет weak-table/barrier semantics при текущем collector без persistent young-generation phases и возвращаетgengc.luaв green. - Публичный
runBytecodeвозвращает толькоVm.Error; приватныйrunBytecodeInternalлокализуетDispatchError/ThreadSwitchвнутри execution layer. - Strict-global codegen больше не содержит списка имён stdlib.
_ENVостаётся единственным специальным lexical mechanism, аprint,requireи прочие runtime globals должны быть объявлены по тем же правилам, что и пользовательские имена. Это проверяетtests/smoke/41_strict_globals_env.lua. lua-5.5.0/testes/time.txtне является semantic fixture: это генерируемыйall.luacache времени предыдущего запуска. Matrix runner snapshot/restore'ит его, если файл присутствовал, и оставляет отсутствующим, если его не было в исходном дереве.
Debt-gate заменён persistent collector state machine, близкой к PUC
gcstate:
pause -> propagate -> atomic -> sweep -> pause
gc_step_active,gc_step_remaining_bytes,gc_in_cycle,resetGcStepDebt,gcStepBudgetBytesи прежний deferred-full-cycle path удалены. Mark sets, gray queue, weak/finalizer queues, registry snapshots и sweep cursor теперь принадлежатVmи сохраняются между вызовамиcollectgarbage("step", n).- Каждый incremental step расходует реальный work budget
max(1, n * stepmul / 100): один work unit обрабатывает один gray object либо один registry entry sweep. Полныйcollectиспользует ту же машину, прокручивая её доpause, а не отдельный атомарный collector. - Mutable roots пересканируются между slices, но immutable VM roots,
Proto/string caches и остальные долговечные registries обходятся только в
начале цикла. Это устраняет повторный full-root scan в
load()-heavy коде. - Table writes, metatable replacement, closure environment и upvalue writes получили conservative tri-color barriers. Ephemeron propagation выполняет fixpoint с drain persistent gray queue между проходами.
- Sweep ограничен budget и удаляет registry entry до освобождения объекта.
Reverse cursor +
swapRemoveсохраняют объекты, созданные после snapshot, и не оставляют dangling pointer в registry между slices. - Регистрация
__gcотслеживается epoch-счётчиком: finalizable, добавленный после atomic-фазы текущего цикла, гарантированно запускает следующий цикл. Closing finalizers выполняются приgc_busy=true, поэтому teardown не допускает reentrant collection. - Добавлен unit regression
vm: incremental GC advances real phases and preserves barrier writes. Он продвигает collector по одному work unit, наблюдает отдельныеpropagate/sweepslices, проверяет освобождение до завершения цикла и сохранение pre-cycle объекта, опубликованного в уже просканированную таблицу.
Подтверждённые проверки на Zig 0.16.0:
zig build -Doptimize=Debug— PASS;zig build test -Doptimize=Debug— PASS;- differential smoke — 42/42 PASS;
- iterative dispatch stress под 1-МБ host stack — PASS;
- upstream matrix с
_port=true; _soft=trueи timeout 60 с на suite — 29/29 PASS; - direct
constructs.luaбез_soft— 55.90 с, exit 0,OK; - direct
verybig.luaбез_soft— 7.44 с, exit 0,OK; - targeted GC/coroutine/debug suites (
gc.lua,gengc.lua,closure.lua,coroutine.lua,db.lua) — PASS; gc.luaс matrix prelude_port=true; _soft=true— 1.85 с в Debug и 0.54 с в ReleaseFast в verification-контейнере (критерии <5 с / <1 с выполнены).
Direct constructs.lua проходит заданный 60-секундный лимит в этом
контейнере, но запас составляет около четырёх секунд, поэтому это измерение,
а не гарантия времени на любой машине.
Atomic weak/finalizer transition пока остаётся одной indivisible work unit: mark propagation и registry sweep инкрементальны, а операции, меняющие наблюдаемую weak/finalizer семантику, завершаются внутри atomic boundary, как и в PUC.
zigиз system toolchain.- C toolchain для reference Lua:
make,gccили совместимый compiler. - Инициализированный upstream test suite submodule.
На Arch Linux:
sudo pacman -S --needed zig gcc makeПроверка Zig:
zig versionИнициализация submodule:
git submodule update --init --recursiveСобрать reference Lua на C:
make lua-c
./build/lua-c/lua -vСобрать Zig implementation:
zig build -Doptimize=Debug
./zig-out/bin/luazig --help
./zig-out/bin/luazigc --helpЗапустить release gate:
tools/release_gate.shReference implementation:
./build/lua-c/lua./build/lua-c/luac
Zig implementation:
./zig-out/bin/luazig./zig-out/bin/luazigc
--engine=ref удалён. Для reference behavior нужно запускать lua/luac напрямую. --engine=zig оставлен как no-op для старых скриптов.
Основные директории:
src/bin/— CLI entrypoints:luazig,luazigc.src/lua/— реализация языка: lexer, parser, AST, IR, codegen, VM, stdlib, API shim.src/util/— общие utility wrappers, включая текущий Zigstd.Iostdio layer.lua-5.5.0/— vendored PUC Lua 5.5.0 release:src/(reference C source, из которого собираетсяbuild/lua-c/lua/luacдля differential-тестов) иtestes/(upstream test corpus, завендорен из v5.5.0 tag).tools/— differential runners, release gate, perf tooling, heavy/OOM probes.tools/perf/— core perf baselines/current snapshots.
Основной runtime path:
src/lua/lexer.zigчитает source bytes и выдаёт tokens.src/lua/parser.zigстроит AST.src/lua/codegen_bc.zigкомпилирует AST → bytecode (Proto).src/lua/vm.zig:runBytecode()исполняет bytecode на shared stack.- IR path (
codegen.zig→runFunctionArgsWithUpvalues) заморожен, доступен через--vm=ir. src/lua/lower_ir.zigиsrc/lua/bc_vm.zigdisabled (старый experimental backend, будет удалён).src/lua/api.zigсодержит публичный Zig-facing API и testC-facing compatibility layer.
Тестовая стратегия основана на differential testing: один и тот же upstream Lua test запускается на PUC Lua и на luazig, затем сравниваются exit code и output.
Reference запускается напрямую:
build/lua-c/luabuild/lua-c/luac
Zig implementation запускается напрямую:
zig-out/bin/luazigzig-out/bin/luazigc
Основные test lanes:
tools/run_tests.py— targeted differential runner для одного или нескольких suites.tools/testes_matrix.py— пофайловая matrix поlua-5.5.0/testes/*.lua.tools/testes_matrix_safe.sh— matrix под memory/time wrapper, чтобы тяжёлые tests не убивали Codex/session.tools/iterative_dispatch_stress.sh— 1-МБ host-stack regression для call/metamethod/coroutine dispatch.tools/testc_lane.py— officialtestClane через Lua test DSL.tools/api_regression_lane.py— Zig unit/integration tests + testC lane + targeted parity.tools/perf_core_snapshot.py— замер core perf suites.tools/perf_guard_core.py— защита от perf regressions относительно baseline.tools/release_gate.sh— единая команда для проверки release/readiness состояния.
Запустить targeted parity:
python3 tools/run_tests.py \
--suite nextvar.lua \
--suite coroutine.lua \
--suite calls.lua \
--suite files.lua \
--suite locals.lua \
--suite db.lua \
--suite gc.luaЗапустить full safe matrix:
tools/testes_matrix_safe.shПо умолчанию matrix запускает upstream tests с prelude:
_port=true; _soft=trueЭто важно для интерпретации результата:
_port=trueотключает непереносимые OS/shell/locale/filesystem checks._soft=trueотключает или сокращает resource-heavy ветки official suite.big.luaв таком режиме не выполняет тяжёлую часть и сразу возвращает'a':if _soft then return 'a' end.verybig.luaв таком режиме выполняет только RK-prefix и пропускаетtesting large programs (>64k):if _soft then return 10 end.- Поэтому
big.lua passв safe matrix означает soft-mode parity, а не полное standalone прохождение тяжёлогоbig.lua; аналогичноverybig.lua passозначает, что large-program ветка не покрыта safe matrix.
Платформенная ветка files.lua (popen/execute, без _port=true) содержит
platform-dependent тесты: sh -c 'kill -s HUP $$' (line 814) падает одинаково
и в luazig, и в PUC Lua на этой системе (shell получает signal вместо ожидаемого
exit). Portable matrix (_port=true) для files.lua проходит идентично PUC Lua.
- Standalone
big.luaбез_softне является корректным простым запуском: upstream файл содержитcoroutine.yield'b'и вall.luaрассчитан на запуск черезcoroutine.wrap(loadfile(...)). При прямом запуске PUC Lua ожидаемо падает сattempt to yield from outside a coroutine;luazigдолжен совпадать с этим поведением/сообщением как отдельный quality item.
Корректные команды для сравнения soft matrix behavior:
./build/lua-c/lua -e '_port=true; _soft=true' lua-5.5.0/testes/big.lua
./zig-out/bin/luazig -e '_port=true; _soft=true' lua-5.5.0/testes/big.lua
./build/lua-c/lua -e '_port=true; _soft=true' lua-5.5.0/testes/verybig.lua
./zig-out/bin/luazig -e '_port=true; _soft=true' lua-5.5.0/testes/verybig.luaКорректный способ проверить real big.lua path — запускать его через all.lua
без _soft либо отдельной coroutine-обвязкой, потому что именно так upstream
ожидает yield в конце файла:
cd lua-5.5.0/testes
/home/boss/codes/luazig/build/lua-c/lua -e '_port=true' all.lua
/home/boss/codes/luazig/zig-out/bin/luazig -e '_port=true' all.luaЗапустить matrix без wrapper, если окружение безопасно:
python3 tools/testes_matrix.py --no-build --timeout 120 --json-out /tmp/testes-matrix.jsonЗапустить API/testC lane:
python3 tools/api_regression_lane.py --timeout 180 --testc-timeout 120Запустить perf snapshot и guard:
python3 tools/perf_core_snapshot.py --out /tmp/core-current.json --timeout 240
python3 tools/perf_guard_core.py \
--baseline tools/perf/core_baseline.json \
--current /tmp/core-current.json \
--max-regression 0.15Главная команда проверки текущего состояния:
tools/release_gate.shGate выполняет:
zig build test -Doptimize=Debug- official
testClane - targeted parity suites
- iterative dispatch stress под 1-МБ host stack
- full safe matrix
- core perf snapshot
- perf guard
Текущий ожидаемый результат gate для bc VM: green по correctness lane — build/unit, official testC, differential smoke, iterative-dispatch stress и 29/29 portable/soft upstream matrix. Performance guard пока служит regression gate, а не подтверждением parity с PUC Lua.
Функциональная parity не означает performance parity. На текущем состоянии
(bc VM после P15.30.1, оптимизации P15.31–P15.39) геометрическое среднее
замедления от PUC Lua 3.47× на microbench (см. P15.37 baseline
tools/perf/baseline-p15.37.json для точной разбивки по workload'ам). Раздел фиксирует измеренный baseline,
известные причины и порядок оптимизаций. Он является частью проектного контракта:
performance-задачи нельзя закрывать только локальным улучшением одного benchmark
без проверки общего профиля и correctness gates.
Дата базового замера: 2026-07-14. Сравнивались:
- luazig, Zig 0.16.0,
ReleaseFast,--vm=bc; - PUC Lua 5.5, собранный из vendored
lua-5.5.0; - один и тот же CPU через
taskset -c 0; - медиана 3–5 прогонов коротких изолированных workloads;
/usr/bin/timeдля max RSS;- ptrace IP sampling с символизацией через
llvm-symbolizer; - временная opcode instrumentation для подсчёта dispatch-инструкций;
- A/B-сборки, где менялась только одна деталь.
Обычный Linux perf в verification-контейнере недоступен: старое ядро 4.4
возвращает ENODEV из perf_event_open. Поэтому baseline не содержит hardware
counters (cycles, IPC, branch misses, LLC misses). При запуске на современном
ядре roadmap P15.37 должен дополнить существующие измерения настоящими
perf stat/perf record, но отсутствие counters не мешает уже сейчас
разделить codegen, dispatch, allocator, table layout и compiler pipeline.
После P15.36 замеры на современном ядре 7.0.11-arch1 (CPU hybrid atom/core,
taskset -c 0, ReleaseFast build) дали первую аппаратную картину. Геометрическое
среднее замедления упало с 11.9× (baseline P15.30.1) до 5.21×.
После P15.37 (hotspot-driven fixes) — 4.77× (см. tools/perf/baseline-p15.37.json).
После P15.38a–d (codegen opcode reduction) — 3.69×.
После P15.38e (PendingCallSlot.get() copy elimination) — 3.74× (within noise; lua_calls -3%, memcpy eliminated).
После P15.38f (debug hook fast path) — 3.71× (lua_calls -8.9%, debug hook overhead eliminated).
После P15.38g (GETTABLE/SETTABLE fast path) — 3.53× (array_access -34.6%, hash_access -31.9%).
После P15.38h (tostring stack-buffer) — 3.43× (string_loop -9.9%, heap alloc eliminated).
После P15.38i (PUC-style builtin results on bc_stack) — 3.42× (hash_access -4.6%, array_access -4.4%).
После P15.39 (variant TKey, Node 48B→32B) — 3.47× на microbench (cache плотность
не помогает tiny tables, см. P15.39); -14% wall / -83% LLC misses на 100K-key hash.
После P15.40 (inline + dedup: SETLIST fast path, OP_CALL inline hook check, inline array-part
fast path, overflow check merge, resolveProtoConstants/popBytecodeExecFrame inline, has_open_upvalues
flag) — 2.95×.
После P15.41 (inline bcConstToValue, MOVE fast path via has_open_upvalues, @branchHint on cold paths) — 2.86×.
Сводная таблица по hotspot-функциям на worst-абсолютных/множительных workloads
(perf record --call-graph lbr, percent-limit 1). Колонка «P15.37 fix» показывает,
какая задача закрыла hotspot:
| Workload | Worst hot symbol | Доля до P15.37 | P15.37 fix | Доля после |
|---|---|---|---|---|
lua_calls (18.6×→6.6×) |
compiler_rt.memset |
57% | P15.37a (frame slim + pending_call split) | <1% |
global_arith (6.9×→6.3×) |
ltable.nodeLookup |
28% | P15.37b (Node 56B→48B) | ~28% (marginal) |
global_arith (6.9×→6.3×) |
metamethod-check (indexValueDepth+tryPushBytecodeNewIndexMetamethod) |
11% | P15.37c (Table.flags BITRAS) | ~9% |
branch_loop (6.0×→6.1×) |
runBytecodeDispatch (весь loop) |
98% | — (instruction-count bound) | ~98% |
comparisons (7.6×→7.8×) |
runBytecodeDispatch |
98% | — (instruction-count bound) | ~98% |
metamethod_add (7.7×→4.9×) |
compiler_rt.memset |
36% | P15.37a (frame slim + pending_call split) | <1% |
hash_access (7.3×→7.7×) |
ltable.nodeLookup |
14% | P15.37b (Node 56B→48B) | ~14% (marginal) |
Главные выводы из hotspots:
-
memsetдоминирует везде, где есть call/return (Недостаток 6, P15.35 недоработан). Это НЕ@memset(regs)— его удалили в P15.36. Источник — инициализация больших структур фреймов приframes.append: компилятор эмититcompiler_rt.memset(byte-wise, не векторизованный!) для zero-initRuntimeFrame(~280 B) иBytecodeExecFrame(~936 B, с большимpending_callunion 760 B) на каждом call/return.- 29% от lua_calls —
pushBytecodeExecFrame → append - 28.5% от lua_calls —
completeBytecodeExecFrame → applyBytecodePendingResults - 36% от metamethod_add — тот же call machinery
P15.37a закрыл: reuse-pool pattern (
addOne+ field-by-field writes) вместо struct-literalappend, плюсPendingCallSlotwrapper (active: bool+payload: BytecodePendingCall = undefined) вместо?BytecodePendingCall = null.compiler_rt.memsetдоля упала с 57% до <1%.
- 29% от lua_calls —
-
Dispatch loop "branch-heavy" workloads (
branch_loop,comparisons) упираются не в branch misses, а в instruction count (Недостаток 1, 3). IPC=4.24 и IPC=3.88 — высокие, branch misses ≤0.02%. Это значит, что CPU выполняет инструкции быстро, но их слишком много: per-Lua-iteration dispatch overhead + codegen производит лишние опкоды (MOVE/LOADNIL/CLOSE). P15.37 не закрывал — требует codegen-level работы (P15.38+). -
Table lookup — главная доля
global_arith/hash_access(Недостаток 4, P15.34 недоработан). 28% nodeLookup + ~14% keyHash/wyhash на global_arith. ТекущийNodeбыл 56 B (key 16 + value 16 + hash 8 + dead_key 1 + next* 8 + padding), PUC Node = 24 B (TKey 8 + TValue 16, chain packed в TKey TValue). P15.37b закрыл частично: Node уплотнён до 48 B черезnext_offset: i32(PUCgnext-faithful) +dead_keypacked в старший битhash. Cache line (64 B) вмещает 1.33 наших Node vs 2.67 PUC Node — всё ещё хуже, но ближе. Полная parity требует variant TKey (P15.38+). -
Metamethod-check overhead (Недостаток 2, частично). 11% от global_arith тратится в
indexValueDepth+tryPushBytecodeNewIndexMetamethod— каждая global get/set проверяет metatable даже для plain tables без метаметодов. P15.37c закрыл:Table.flagsbitmask (PUC BITRAS,ltm.h:54) — bit set = "metatable не имеет этого метаметода", skipgetFieldOpt. Доля упала с 11% до ~9% (меньше ожидаемого, т.к.global_arithработает с_ENVбез metatable — null-check уже short-circuit'ит). -
Perf работает на современном ядре. README baseline писал "perf недоступен в verification-контейнере". При локальной разработке нужно использовать
perf stat/perf record --call-graph lbr— даёт точную hotspot-карту.taskset -c 0обязателен для шумящей hybrid-архитектуры (atom/core).
План P15.37 (см. docs/superpowers/plans/2026-07-18-performance-phase-p15.37.md)
перевёл эти findings в конкретные задачи. Все 4 задачи (a/b/c/d) выполнены.
| Нагрузка | PUC Lua, медиана | luazig, медиана | Замедление |
|---|---|---|---|
| integer arithmetic | 0.058 с | 1.240 с | 21.4× |
| global access + arithmetic | 0.054 с | 0.965 с | 17.8× |
| branch loop | 0.060 с | 1.016 с | 16.9× |
| Lua calls | 0.047 с | 0.736 с | 15.7× |
| array table access | 0.041 с | 0.372 с | 9.1× |
| integer hash table access | 0.065 с | 0.633 с | 9.7× |
| temporary table allocation | 0.058 с | 1.022 с | 17.5× |
| string-heavy loop | 0.050 с | 0.633 с | 12.6× |
| coroutine resume/yield | 0.023 с | 0.144 с | 6.1× |
dynamic load() |
0.043 с | 0.207 с | 4.8× |
Геометрическое среднее на этой выборке — примерно 11.9×. Абсолютные числа зависят от CPU и allocator, но относительный профиль устойчив: сильнее всего отстают arithmetic/global/branch/calls и массовые table allocations.
Для простого цикла:
local s = 0
for i = 1, n do
s = s + i
endluazig выполняет примерно семь opcode на итерацию:
MOVE× 3;LOADNIL;ADD;CLOSE;FORLOOP.
В PUC Lua подготовительные операции находятся вне горячего тела; на итерацию
остаются в основном arithmetic opcode и FORLOOP.
Причины:
- Выражения преждевременно материализуются во временные регистры, после чего
копируются через
MOVE. resetRegs()консервативно эмититLOADNILна границе statement, чтобы убрать stale GC references, вместо точного register liveness.- Numeric
forполучаетCLOSE, даже когда ни одна local переменная scope не захвачена closure и закрывать нечего. - Нет PUC-подобных immediate/constant forms (
ADDI,ADDK, RK operands и аналогов для comparisons/table operations).
Это главный общий bottleneck: даже идеально оптимизированный ADD не устранит
несколько лишних dispatch cycles на каждую Lua-операцию.
Способ устранения: P15.32 должен ввести PUC-подобный descriptor выражений,
сохранять значения как register/constant/immediate до фактической
materialization, писать результат прямо в destination и генерировать
LOADNIL/CLOSE только по точному liveness/upvalue анализу.
Sampling integer arithmetic loop:
| Hot path | Доля samples |
|---|---|
общий runBytecodeDispatch |
~42% |
coerceArithmeticValue |
~23% |
| bytecode close continuation | ~12% |
evalBinOp + binAdd |
~10% |
| frame/upvalue/прочее | остаток |
Обычный numeric ADD проверяет и преобразует операнды дважды: сначала перед
решением о metamethod, затем повторно внутри generic arithmetic helper.
A/B-сборка только с прямым Int/Num fast path для ADD, без изменения bytecode,
ускорила benchmark примерно с 1.44 до 0.95 с — около 1.5×.
Способ устранения: P15.31 вводит typed fast paths для arithmetic, bitwise,
comparisons, GETI/SETI и interned-string GETFIELD/SETFIELD. Generic
coercion/metamethod path должен вызываться только после провала дешёвых tag
checks. При этом обязателен точный PUC-порядок conversions, ошибок и выбора
metamethod.
Общий dispatch path даже в простом no-hook/no-yield случае обновляет или проверяет:
RuntimeFrame.pc,top,current_line, varstack state;- line/count hooks;
- GC debt/tick;
- pending completion и result state;
- coroutine/thread-switch boundaries;
- boxed/register state;
- debug metadata.
PUC Lua хранит hot state в локальных переменных luaV_execute и публикует его
в lua_State/CallInfo на safepoints. В luazig значительная часть cold-path
машинерии остаётся внутри общего interpreter loop.
Способ устранения: P15.33 разделяет fast и slow dispatch. В fast loop pc,
base, top и current closure живут в локальных Zig-переменных. Runtime structures
синхронизируются только на call/return, allocation/GC safepoint, hook,
error/metamethod, yield/resume и debug API boundary. Continuation/debug/error
код выносится из hot function для уменьшения instruction-cache footprint.
Текущие размеры:
| Тип | luazig | PUC Lua |
|---|---|---|
Value / TValue |
16 B | 16 B |
Table |
72 B | 48 B |
hash Node |
56 B | 24 B |
Closure / LClosure |
72 B | 40 B |
Cell / UpVal |
48 B | 40 B |
Thread / lua_State header |
840 B | 208 B |
На integer hash benchmark sampling показывает примерно 31% в nodeLookup,
14% в очистке hash storage, 13% в dispatch и 11% в nodeInsert. Node в 2.3
раза больше PUC-варианта, поэтому на cache line помещается меньше entries,
растут RSS, cache misses и стоимость rehash/memset.
Способ устранения: P15.34 должен уплотнить Node до 24–32 байт, добавить
специализированные integer/interned-string paths, убрать generic Value
hashing для array index и не обнулять большие 56-byte nodes целиком при каждом
росте. Изменение layout принимается только вместе с weak/dead-key/Brent-chain
регрессиями.
CLI использует std.heap.smp_allocator, хотя Lua VM однопоточная. Sampling
показывает mutex/slab paths, а allocation-heavy benchmark — большой разрыв RSS.
A/B для 400 000 временных таблиц:
| Allocator | Время | Max RSS |
|---|---|---|
smp_allocator |
~0.81 с | ~160 МБ |
| libc allocator | ~0.54 с | ~134 МБ |
| PUC Lua | ~0.04 с | ~4 МБ |
Libc allocator даёт около 1.5× на массовых аллокациях, но почти не влияет на lookup-heavy workload. Следовательно, простая замена allocator полезна как промежуточный шаг, но не решает раздутый layout и слишком большое число allocation units.
Способ устранения: после безопасного временного перехода на libc allocator
P15.34 должен добавить однопоточный VM allocator: page/slab pools для
Table/Node/Closure/Cell, отсутствие mutex и bulk release страниц после
major sweep. Compile-temporary allocations должны жить в отдельной arena.
Lua calls отстают примерно в 15–16 раз, coroutine resume/yield — примерно в 6
раз. На обычном вызове участвуют generic resolveCallable, growable arrays
frames/exec-frames, публикация runtime state, pending completion, debug/hook
transfer и close/upvalue machinery.
Способ устранения: P15.35 вводит предвыделенный PUC-подобный CallInfo stack,
компактный frame header и отдельный прямой path для обычного Lua closure call.
resolveCallable, __call, hooks, yields и error continuations остаются slow
path. Debug names реконструируются лениво только при error/debug API.
Dynamic load() отстаёт меньше — около 4.8× — но профиль показывает отдельный
compiler bottleneck:
- allocator/system paths — около 39%;
- создание bytecode closure — около 15%;
- рост AST/name arrays — около 12%;
- memcpy — около 8%.
PUC компилирует parser-to-bytecode почти напрямую, тогда как luazig строит полный AST, повторно обходит его и использует множество независимых growable arrays.
Способ устранения: P15.36 сначала добавляет reuse arena, capacity hints, small-vector storage и сокращение копирований identifier/string data. После стабилизации нужен streaming parser-to-bytecode path; AST остаётся optional режимом для tooling/debug, а не обязательной runtime стадией.
Пункт необходимо подробно обсудить, так как AST было подмечено как одно из улучшений luazig по сравнению с PUC Lua
После P15.30.1 GC correctness и pacing остаются критичными, но сам collector не объясняет основной performance gap. Arithmetic/global/branch loops почти не аллоцируют и всё равно отстают в 17–21 раз. Поэтому следующая крупная оптимизация должна начинаться с typed operations, codegen и dispatch, а не с ещё одного переписывания GC.
- Instruction inflation из codegen.
- Тяжёлый общий dispatch path.
- Generic arithmetic/comparison/metamethod path.
- Table representation и allocator.
- Call-frame machinery.
- AST-based dynamic compilation.
- Дополнительный GC tuning только после измеренного доказательства, что он ограничивает конкретный workload.
Каждая итерация закрывает минимум один чекбокс ниже (см. AGENTS.md).
Дизайн фиксируется здесь же; отступления от PUC отмечаются явно.
P15.31 полностью завершён (8/8 чекбоксов). P15.32b/c завершены. P15.33 (fast/slow dispatch split) частично завершён (4/6 чекбоксов закрыты): hooks_active_cached, RuntimeFrame sync только на safepoints, GC fast check, benchmark. Оставшиеся 2 чекбокса P15.33 (полный вынос state в locals, cold path separation) требуют более глубокой реструктуризации. Следующий приоритет — P15.34 (compact tables), которая даст больший выигрыш на table-heavy workloads.
Цель первого performance-патча — убрать заведомо лишний generic path, не меняя формат bytecode и не смешивая этот этап с крупным codegen redesign.
- Прямой integer/integer и number/number path для
ADD,SUB,MUL,MOD,POW,DIV,IDIVи unary minus. - Прямые paths для bitwise операций и shifts.
- Прямые numeric comparison paths.
- Fast
GETI/SETIдля array/integer keys. - Fast
GETFIELD/SETFIELDдля interned short strings. - Generic coercion и metamethod lookup вызываются только после провала tag checks.
- Debug operand names и дорогая error context не вычисляются без активного
hook/error path. P15.31: CALL/TAILCALL передают
nullвresolveCallable;debugBytecodeOperandName(обратный проход по bytecode) вызывается только в catch-блоке при ошибке "attempt to call a X value". Error messages сохраняют имена (local 'foo',field 'baz'). 31/31 parity проходит. - Добавить opcode-level microbench и differential tests для смешанных
integer/float/string/metamethod случаев. P15.31: Добавлены 6 workloads:
mixed_arith(int+float ADD),float_arith(float+float ADDK),string_concat(CONCAT),metamethod_add(__add),field_access(GETFIELD/SETFIELD),comparisons(LT/LE/EQ chain).
Критерии приёмки:
- arithmetic slowdown снижен примерно с 21× до ≤12×;
- ни один fast path не меняет PUC-порядок conversion/metamethod/error;
- build/unit, smoke, 29/29 matrix, direct
gc.lua,gengc.lua --testc,constructs.lua,verybig.luaи 1-МБ stress остаются зелёными.
Наиболее важный этап общего roadmap.
- Ввести PUC-подобный operand/
expdesc: register, constant, immediate, relocatable jump и materialized temporary являются разными состояниями. P15.32 expdesc:ExpDescstruct с Zig tagged union (19 вариантов:local,upval,k,k_int,k_float,k_str,index_up,index_str,index_i,indexed,reloc,non_reloc,call,vararg,nil,true,false,const_local,non_reloc). Discharge primitives:dischargeVars(PUCluaK_dischargevars),discharge2reg(PUCluaK_discharge2reg),exp2nextreg(PUCluaK_exp2nextreg),exp2anyreg(PUCluaK_exp2anyreg),exp2val(PUCluaK_exp2val),freeExp/freeExps.genExpDesc— аналог PUCluaK_exp2valдля AST→ExpDesc.genNameExpDesc— PUCsinglevar: local→.local, upvalue→.upval, global→.index_str(если_ENVlocal) или.index_up(если_ENVupvalue).genGlobalExpDesc— mirrorsemitGlobalGetat ExpDesc level. Large constant index (>255 interned strings) handled via LOADK+GETTABLE fallback indischarge2regfor.index_up/.index_str. P15.32a (откат):genNameValueвозвращал регистр local напрямую (PUC VLOCAL), но это ломалоrepeat/whileс замыканиями —genExpNextRegнеправильно определял "уже в нужной позиции" для local registers. ДобавленgenExpNextReg(аналогluaK_exp2nextreg) с проверкойr >= nvarstack. Требует полноценный expdesc с relocate-семантикой как в PUC. - Не материализовать local/constant до инструкции, которой действительно
нужен регистр.
P15.32 expdesc:
genBinOp,genReturn,genCallmigrated to ExpDesc.exp2anyregдля non-captured locals возвращает регистр локала напрямую (no MOVE).dischargeVarsдля captured locals эмитит MOVE (cell.value sync). RHSline_hintsave/restore вgenBinOp— инструкции RHS несут правильную строку (не строку statement). Улучшения: int_arith -19%, float_arith -28%, comparisons -19%, branch_loop -17%, mixed_arith -14%. - Писать результат выражения сразу в destination assignment/return slot.
P15.32 expdesc:
genReturnsingle-value path используетexp2anyreg— для non-captured localsreturn1читает регистр локала напрямую (no MOVE).genCallarguments используютgenExpNextReg(ExpDesc path) — аргументы discharge напрямую в call frame slots (no MOVE for non-captured locals). - Добавить
ADDI,ADDKи аналогичные immediate/constant variants. P15.32c: Добавлены opcodesaddi,addk,subk,mulk,modk,powk,divk,idivk,bandk,bork,bxork,shli,shri(PUC 5.5 style).genBinOpпроверяет RHS на numeric constant черезnumericConstFromExp(аналог PUCtonumeral) и эмитит K/I-variant вместо LOADK + register op.constBinOpInfoопределяет opcode/C-field без модификации регистров (clean fallback). SUB с small int использует SUBK (не ADDI), т.к. наш bytecode не имеет MMBINI для передачи правильного metamethod event. 31/31 parity matrix проходит. Улучшения: branch_loop -6.4%, array_access -11.5%, hash_access -6.7%. - Добавить RK-подобные operands для arithmetic и table instructions.
P15.32c: 13 новых опкодов (addi, addk, subk, mulk, modk, powk, divk,
idivk, bandk, bork, bxork, shli, shri) в
bytecode.zig. Codegen:numericConstFromExpизвлекает числовые константы из AST без материализации в регистр,constBinOpInfoопределяет опкод и C-поле,tryEmitConstBinOpэмитит K/I-вариант с чистым fallback. VM: fast/slow path handlers с metamethod fallback.evalBytecodeBinOpValuesдля аннотирования ошибок. Улучшения: branch_loop -6.4%, array_access -11.5%, hash_access -6.7%. Bug fix:env_cellвrunZigSourceперемещён с стека в heap (aalloc.create(Cell)) — closures захватывают cell как upvalue, а finalizers (__gc) могут выполняться во времяvm.deinit(), долго после возвратаrunZigSource. Stack-local cell приводил к use-after-free. - Эмитировать
CLOSEтолько для scopes с реально открытыми upvalues/TBC. P15.32b:anyCapturedInRange(start, end)проверяетcaptured_regsперед эмитом CLOSE вgenForNumeric,genWhile,genRepeat,genForGeneric.popScopeуже имел эту проверку. TBC-переменные в generic-for сохраняют безусловный CLOSE (semantics__close). - Заменить statement-wide
LOADNILточным register liveness и очисткой на safepoints. P15.32 LOADNIL elimination: Добавлено per-PClive_reg_topполе вProto([]const u8, один байт на инструкцию). Codegen синхронизируетbuilder.current_live_topсpeak_freeregпри каждомreserveRegs/resetRegs/popScope. GC (gcMarkMutableRoots) маркирует толькоframe.regs[0..live_reg_top[pc]]вместо полногоmaxstacksizeокна — PUCtraversestackподход. Atomic phase (gcClearDeadFrameRegisters) очищает GC pointers из dead slots вышеlive_reg_top[pc]— PUCsetnilvalueэквивалент.resetRegsиpopScopeбольше не эмитят LOADNIL. Улучшения: int_arith -23%, float_arith -27%, mixed_arith -21%, comparisons -10%. 30/31 parity (cstack pre-existing). - Добавить bytecode dump regression, фиксирующий размер hot loops.
P15.32c:
--dump-bytecodeCLI flag вызываетdumpProto()изbytecode.zig(luac-style disassembly с прото, константами, локалами, upvalues). Регрессионные тесты вcodegen_bc.zig: проверка ≤7 опкодов между FORPREP/FORLOOP и проверка эмитирования ADDI дляx + 5.
Результаты microbench (медиана из 3 прогонов, taskset -c 0, ReleaseFast):
| Benchmark | PUC Lua | До P15.31 | P15.31 | P15.32b | P15.32c | P15.33 | P15.32nil | P15.32exp | P15.33b | P15.34 | P15.36 | P15.37 | PUC→P15.37 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| int_arith | 0.173 | 9.605 | 8.715 | 1.735 | 1.779 | 1.358 | 1.050 | 0.848 | 0.832 | 0.853 | 0.844 | 0.841 | 4.9× |
| global_arith | 0.504 | 15.863 | 14.879 | 7.716 | 8.099 | 7.220 | 6.881 | 6.599 | 6.599 | 3.059 | 3.568 | 3.187 | 6.3× |
| branch_loop | 0.422 | 12.913 | 11.344 | 4.164 | 3.963 | 3.453 | 3.216 | 2.634 | 2.607 | 2.611 | 2.550 | 2.576 | 6.1× |
| lua_calls | 0.093 | 3.523 | 3.427 | 2.719 | 2.642 | 2.553 | 2.520 | 2.503 | 2.443 | 2.527 | 1.747 | 0.617 | 6.6× |
| array_access | 0.044 | 1.315 | 1.155 | 0.426 | 0.368 | 0.338 | 0.309 | 0.293 | 0.293 | 0.291 | 0.285 | 0.289 | 6.6× |
| hash_access | 0.063 | 1.655 | 1.395 | 0.644 | 0.595 | 0.556 | 0.507 | 0.491 | 0.491 | 0.489 | 0.469 | 0.481 | 7.7× |
| temp_table_alloc | 0.029 | 0.224 | 0.223 | 0.154 | 0.147 | 0.147 | 0.137 | 0.137 | 0.137 | 0.141 | 0.133 | 0.134 | 4.7× |
| string_loop | 0.097 | 0.528 | 0.533 | 0.448 | 0.451 | 0.448 | 0.447 | 0.436 | 0.436 | 0.411 | 0.428 | 0.409 | 4.2× |
| coroutine_yield | 0.042 | 0.429 | 0.411 | 0.329 | 0.320 | 0.314 | 0.298 | 0.306 | 0.306 | 0.237 | 0.220 | 0.222 | 5.3× |
| dynamic_load | 0.129 | 1.224 | 1.122 | 0.409 | 0.408 | 0.370 | 0.350 | 0.319 | 0.319 | 0.314 | 0.337 | 0.267 | 2.1× |
| mixed_arith | 0.176 | — | — | — | 1.834 | 1.514 | 1.193 | 1.000 | 1.000 | 0.993 | 0.977 | 0.982 | 5.6× |
| float_arith | 0.177 | — | — | — | 1.812 | 1.384 | 1.017 | 0.740 | 0.656 | 0.729 | 0.648 | 0.661 | 3.7× |
| string_concat | 0.081 | — | — | — | 0.150 | 0.150 | 0.148 | 0.139 | 0.139 | 0.132 | 0.128 | 0.128 | 1.6× |
| metamethod_add | 0.058 | — | — | — | 0.556 | 0.544 | 0.549 | 0.545 | 0.545 | 0.475 | 0.456 | 0.282 | 4.9× |
| field_access | 0.054 | — | — | — | 0.732 | 0.658 | 0.583 | 0.587 | 0.587 | 0.242 | 0.230 | 0.228 | 4.2× |
| comparisons | 0.530 | — | — | — | 7.070 | 5.705 | 5.132 | 4.233 | 4.090 | 4.250 | 4.122 | 4.112 | 7.8× |
Геометрическое среднее: 2.12× ускорения (до P15.31 → P15.32c, 10 исходных workloads);
8.7× отставания от PUC Lua (PUC → P15.32c, 10 исходных). Новые workloads (P15.31):
mixed_arith 10.1×, float_arith 10.0×, string_concat 1.8×, metamethod_add 9.4×,
field_access 13.1×, comparisons 13.1×. P15.32exp (ExpDesc migration): int_arith -19%,
float_arith -28%, comparisons -19%, branch_loop -17%, mixed_arith -14%.
P15.33b (stack ptr tracking + branch hints): float_arith -11%, comparisons -3%,
lua_calls -2%.
P15.34 (pre-intern string constants): global_arith -54%, field_access -59%,
metamethod_add -13%, string_loop -6%, coroutine_yield -23%.
P15.36 (eliminate per-call @memset via before-semantics + upvalue closing): lua_calls -31%
(2.527→1.747s). Parity: 28/31 (no regressions).
P15.35 (zero-allocation call/return fast path): lua_calls -27% (7.27→5.32s, Debug build).
P15.37a (frame-struct slim + pending_call split): lua_calls -65% (1.760→0.617s),
metamethod_add -38% (0.456→0.282s), compiler_rt.memset share 53%→<1%.
P15.37b (Node compaction 56B→48B via i32 chain offset): PUC gnext-faithful,
nodeLookup perf share unchanged (~28%).
P15.37c (Table.flags BITRAS): PUC-faithful metamethod cache, skips getFieldOpt
when metatable has no __index/__newindex.
P15.37 geomean: 4.77× (16 workloads, median of 7, tools/perf_compare.py).
P15.32a (устранение MOVE для local reads) был отменён — ломал repeat/while
с замыканиями. P15.32b (условный CLOSE) даёт основной вклад: int_arith
8.7→1.7s, branch_loop 11.3→4.2s, dynamic_load 1.1→0.4s. P15.32c (K/I-variant
opcodes) устраняет LOADK для constant-operand arithmetic: branch_loop -6.4%,
array_access -11.5%, hash_access -6.7%. P15.32exp (ExpDesc migration) устраняет
MOVE для non-captured locals в genBinOp/genReturn/genCall: int_arith 1.05→0.85s,
float_arith 1.02→0.74s, comparisons 5.13→4.23s.
Критерии:
s=s+iвыполняет не более 2–3 hot opcode на итерацию вместо примерно 7;- arithmetic/global/branch slowdown — ≤5× PUC;
- generated code остаётся корректным при hooks, GC между инструкциями,
captured locals,
gotoи<close>.
- Отдельный compact loop для no-hook/no-yield/no-pending/no-GC case.
P15.33: Когда
hooks_active_cached == false, dispatch loop пропускает per-instruction RuntimeFrame sync, line/count hook checks и debug corruption assertion.hooks_active_cached— Vm-level bool, обновляемый черезrefreshHooksCached()после каждогоdebug.sethookи при каждом coroutineswitchRuntime.current_threadустанавливается передswitchRuntime, чтобы cached flag читал hook state правильного thread. -
pc, base, top и closure живут в локальных переменных interpreter loop. P15.33:pc,regs,boxed,frame_current_line,frame_cap,nvarstack,reg_top— все локальные в dispatch loop. Stack ptr tracking:stack_ptr/stack_boxed_ptr/cached_frame_capпроверяются каждый iteration, re-deriveregs/boxedтолько когда stack был realloc'd (после CALL/RETURN/ builtin). На fast path — один pointer comparison вместо двух slice ops.@branchHint(.unlikely)на hooks_active slow path. - RuntimeFrame синхронизируется только на safepoints.
P15.33: RuntimeFrame sync только на: defer (frame exit), GC tick, error
paths (
fail(),error(),setOutOfMemoryError()), builtin entry (callBuiltin).bc_dispatch_pcобновляется на fast path (один store),frame_current_lineобновляется изlineinfo[pc]. - Cold continuation/debug/error paths вынесены из hot function.
P15.33:
@branchHint(.unlikely)на hooks_active slow path — compiler move cold debug hook code out of hot path. Arithmetic metamethod slow paths оставлены inline (compiler уже хорошо справляется с branch prediction дляif/else ifchains). Microbench: float_arith -10% от stack ptr tracking. - GC fast check сводится к дешёвому debt comparison; реальное продвижение
collector выполняется на allocation/safepoint path.
P15.33: Fast path:
gc_tick += 1; if (gc_tick >= threshold) { sync+step }. Только counter increment + comparison, без вызова функций. - Добавить benchmark и assertion, что выключенные hooks не ведут через hook
machinery.
P15.33:
hooks_active_cached— single bool read per instruction. Microbench подтверждает: int_arith -24%, float_arith -24%, comparisons -19%.
Критерии:
- arithmetic после P15.32 приближается к 2–3× PUC;
- Lua calls — ≤5× PUC до отдельного CallInfo redesign;
- debug hooks, coroutine switch и error unwinding сохраняют текущую parity.
-
Уменьшить hash
Nodeс 56 до 48 байт. P15.37b:next: ?*Node(8 B + padding) →next_offset: i32(4 B) PUCgnext-style,dead_keypacked в старший битhash. Полная parity (24 B) требует variant TKey (P15.38+). -
Уплотнить chain metadata и исключить дублирующие key/tag fields. P15.39: variant TKey —
key: Value(16 B tagged union) →key_tt: u8+key_val: extern union(8 B), кэшированныйhash: u64удалён (хеш пересчитывается на каждом use site как в PUC).Nodeсжат с 48 B до 32 B → две полные nodes на 64-byte cache line. -
Специализированные integer и interned-string lookup/insert paths.
-
Не memset’ить полный большой Node при каждом resize. P15.37a: struct literal zero-init заменён на reuse-pool pattern (
addOne+ field writes). -
Сначала проверить libc allocator как безопасный промежуточный default для CLI.
-
Затем добавить VM-local pools/pages для
Table,Node,Closure,Cell; без mutex. -
Освобождать пустые pages после major sweep.
-
Compile/parser temporary data вынести в переиспользуемую arena.
-
Pre-intern string constants at first Proto execution.
bcConstToValuepreviously calledinternStrAll(s.bytes())on every GETTABUP/GETFIELD/SETFIELD execution — re-hashing already-interned strings (~12.4% of cycles on microbench). NowresolveProtoConstantsruns once per Proto (inpushBytecodeExecFrameand TAILCALL frame-reuse path), replacing compile-time*LuaString(hash seed 0) with VM-interned pointers.bcConstToValuereturns the pointer directly. Ownership: after resolution, string constants are owned by the VM's intern table, not by the Proto (matching PUC'sTStringownership model). Results: global_arith -54%, field_access -59%, metamethod_add -13%. -
Nil-fill missing parameters (PUC
luaD_precallparity).pushBytecodeExecFramenow explicitly nil-fillsregs[args.len..numparams]instead of relying on the full-window memset. Matches PUC'sfor (; narg < nfixparams; narg++) setnilvalue(s2v(L->top.p++)). -
Bound debug.getlocal/setlocal temp-scan by
live_reg_top[pc]. Previously scanned the entire register window (fr.regs[0..fr.regs.len]), exposing stale values as phantom "(temporary)" locals. Now bounded bylive_reg_top[pc](our equivalent of PUC'sL->top), with fallback to full window for stripped/external protos withoutlive_reg_top. Fixes latent db.lua assertion failure.cloneStrippedProtonow copieslive_reg_top. -
Fix parked-coroutine GC scan to use per-frame
live_reg_top. Previously scanned the entirebytecode_stack[0..parked_top]as a flat array with NO per-framelive_reg_topfiltering — stale values in uninitialized register windows caused SIGSEGV on coroutine.lua/cstack.lua. Now walksth.runtime_frameswith per-framelive_reg_top[fr.pc], matching the active-thread GC scan atgcMarkMutableRoots.Note on
@memset(regs, .Nil): The full-window memset is still kept as a safety net. It cannot be removed untillive_reg_toptracks the "after" boundary (registers actually written by the completed instruction) rather than the per-statement high-water mark. PUC'sL->topis updated after each instruction; ourlive_reg_top[pc]is set before emission. When GC runs at a safepoint (fr.pc = pcbefore instruction execution),live_reg_top[pc]may include registers that will be written by this instruction but haven't been yet. This is a P15.35 prerequisite.
Критерии:
- array/hash operations — ≤3× PUC;
- temporary table allocation — ≤3× PUC;
- max RSS allocation workload — ≤2× PUC;
- weak tables, dead keys, finalizers, Brent chaining и generational barriers не получают регрессий.
- Предвыделенный массив frame/CallInfo records.
P15.40a: pre-allocate frame capacity in
activateRuntime/apiNewThread/init. P15.40b: mergedCallFramestruct (BytecodeExecFrame + RuntimeFrame → single struct). P15.40b-full: eliminated dual-array pattern —pushBytecodeExecFramedoes ONEaddOnetoThread.call_frameswith ALL fields. No runtime copy push. - Обычный Lua call/return не делает heap allocation на fast path.
P15.35a: OP_RETURN0/OP_RETURN1 fast path — when no pending TBC closers and
no debug hooks, skip
beginBytecodeCloseentirely and go straight tocompleteBytecodeExecFrame. Single-value returns use abc_return_scratchVM-state slot (stable across frame pop, detected by pointer identity, never freed). Zero-value returns usebc_return_scratch[0..0]. This eliminates thealloc(Value, 0)/alloc(Value, 1)+freepair on every return — matching PUC Lua's zero-allocation return semantics. P15.35b: Non-vararg functions use a staticempty_varargsslice instead ofalloc.dupe(Value, &.{})— eliminates the malloc(0)/free(0) pair on every OP_CALL for functions without.... P15.35c: FixedrefreshHooksCachedto includehas_call/has_return— the P15.33 cached flag previously only tracked line/count hooks, causing the OP_RETURN fast path to skip return hooks. Now correctly detects all hook types. P15.35d: Eliminatedrargs = alloc.dupe(...)on OP_CALL fast path. Whenresolved.owned_args == null(no__call), pre-growbc_stackfor the child frame before the dupe check. If no realloc (common), passresolved.argsdirectly. If realloc (rare), re-derive from offset. Matches PUC'sluaD_precall— args addressed by stack offset, no intermediate buffer. P15.35e: Fixedlong_string_cachekey use-after-free ininternStrAll. The cache stored the caller's borrowedrawslice as the key, butresolveProtoConstantsfreed the sourceLuaStringimmediately after. On next grow/rehash, the map compared keys through freed memory → corruption. Fixed: storels.bytes()(interned string's inline body, stable for the string's lifetime). P15.35f: Eliminateddup_args = alloc.dupe(...)on OP_TAILCALL fast path. Passorig_args(regs[a+1..]) directly toresolveCallable. In the frame-reuse path, copy params BEFORE nil-fill usingcopyForwards(overlap-safe, matches PUC's memmove). Compute varargs BEFORE nil-fill. Matches PUC's tail-call path — args moved in-place, no intermediate buffer. Results: lua_calls -27% (7.27→5.32s, Debug build). Parity: 28/31 (up from 26/31; api.lua and literals.lua fixed). - Прямой known-Lua-closure path без повторного
resolveCallable. P15.35: PUCluaD_precallinlined into OP_CALL/OP_TAILCALL/OP_TFORCALL. Fast path (Closure/Builtin) exits inline type switch on first iteration — no function call, no struct, no heap alloc.__calluses in-place stack shift (PUCtryfuncTM).resolveCallablekept for 25 cold-path sites. -
__call, hooks, protected calls, yields и thread switches остаются в slow path. The OP_RETURN0/1 fast path checkshas_pending_tbcandhooks_activebefore taking the shortcut. When TBC closers or any debug hook is active, the fullbeginBytecodeClosepath runs unchanged. - Debug name reconstruction выполняется лениво.
- Уплотнить
Threadheader и parked-frame storage после измерения lifetime требований.
Критерии:
- Lua calls — ≤2–3× PUC;
- coroutine resume/yield — ≤3× PUC;
- iterative-dispatch invariants и 1-МБ stack stress сохраняются.
- Reuse parser/codegen arena между вызовами
load(). - Capacity hints для AST, bytecode, constants и names.
- Small-vector storage для типичных маленьких функций.
- Уменьшить копирование identifier/source/string data.
- После стабилизации добавить streaming parser-to-bytecode backend.
- Полный AST оставить optional tooling/debug path.
Критерий: dynamic load() — ≤2–3× PUC, без ухудшения diagnostics,
lineinfo, stripped chunks и large-program codegen.
- Part 1: Codegen infrastructure for "before" semantics. Added
live_top_before/has_live_top_beforefields toProtoBuilder.reserveRegs/syncLiveTopsnapshot the live top BEFORE bumpingpeak_freereg.emit()recordslive_top_before(the "before" boundary) instead ofcurrent_live_top(the "after" boundary). This ensures GC safepoints only scan registers written by PREVIOUS instructions, not registers that will be written by the current instruction but haven't been yet. - Part 2: Fix
.relocexpression pattern. The.relocpattern (used by GETTABUP, GETTABLE, etc.) emits the instruction BEFORE allocating the destination register, then patches the A field. This causedlive_top_beforeto be snapshotted before the bump, and the stale snapshot persisted to the next instruction (due tohas_live_top_beforeflag). Fixed indischarge2reg's.reloccase: after patching, clear the flag and updatelive_top_before = current_live_topso the next instruction sees the correct boundary. - Part 3: Upvalue closing on return path.
completeBytecodeExecFramenow callscloseBytecodeUpvaluesFrom(frame, 0)BEFOREpopBytecodeExecFrame. Previously, open upvalue cells inboxed[]were never closed on normal returns — masked by@memset(boxed, null)on frame entry. Double-close is safe:closeBytecodeUpvaluesFromchecksboxed[i]for non-null and clears it after closing. - Part 4: Remove both
@memsetcalls. Removed@memset(regs, .Nil)and@memset(boxed, null)frompushBytecodeExecFrame. PUC Lua (luaD_precall) does NOT zero the register window — it only nil-fills missing parameters. We now match this behavior. - Document known deviations from PUC Lua. (1)
gc_tickfires GC every N instructions instead of PUC's allocation-only triggers. (2) Per-PClive_reg_toptable instead of runtimeL->top— needed becausegc_tickcan fire at any instruction. Both deviations are orthogonal to the memset fix but documented as technical debt.
Results: lua_calls -31% (2.527→1.747s, ReleaseFast). Parity: 28/31
(no regressions). All 44 smoke tests pass.
Spec: docs/superpowers/specs/2026-07-18-memset-elimination-design.md
Добавить tools/perf_compare.py и versioned baseline + закрыть 3 hotspot'а,
выявленных через perf record --call-graph lbr:
- ReleaseFast build на Zig 0.16.0;
- CPU affinity и warmup (
taskset -c 0); - медиана минимум семи прогонов;
- arithmetic/global/branch/call/table/allocation/string/coroutine/load
workloads (16 в
tools/microbench.lua); - wall time, process CPU, max RSS и opcode count (только wall time);
- при доступном современном ядре —
perf statcounters (--perfflag); - JSON output с toolchain/CPU metadata (
tools/perf/baseline-p15.37.json); - warning при регрессии >5%, failure >10% для стабильных benchmarks;
- отдельная маркировка noisy/long suites вроде direct
constructs.lua.
Hotspot-driven задачи:
- P15.37a — Frame-struct slim.
BytecodeExecFrame(936 B) иRuntimeFrame(280 B) инициализировались через struct literalappend, что эмитит byte-wisecompiler_rt.memset(57% от lua_calls). Заменено на reuse-pool pattern:addOne(alloc)+ field-by-field writes (без struct literal → нет memset). Дополнительно:?BytecodePendingCall(768 B optional) заменён наPendingCallSlotwrapper (active: bool+payload = undefined), гдеclear()— single-byte write вместо 768-byte memset. Результат: lua_calls 1.760→0.617s (-65%), metamethod_add 0.456→0.282s (-38%),compiler_rt.memsetshare 53%→<1%. - P15.37b — Node compaction.
ltable.Nodeуплотнён с 56 B до 48 B:next: ?*Node(8 B + padding) заменён наnext_offset: i32(4 B) PUCgnext-style signed index offset,dead_key: boolупакован в старший битhash(DEAD_KEY_FLAG). Hash маскируется (HASH_MASK) при записи, чтобы live-key хеши с установленным bit 63 не ложно определялись как dead.nextNodeиспользует direct pointer arithmetic для chain walk. Результат: PUC-faithful layout,nodeLookupperf share ~28% (без регрессии). Полная parity (24 B) требует variant TKey (P15.38+). - P15.37c — Table.flags bitmask (BITRAS). Добавлен
flags: u8вTable(PUCltm.h:54): bit set = "metatable не имеет метаметод". Проверяется передgetFieldOpt(mt, "__index"/"__newindex")в 5 точках. Сбрасывается при new-key insertion (rawSet, PUCltable.c:1112). Результат: PUC-faithful metamethod cache. Доля metamethod-check упала с 11% до ~9% (меньше ожидаемого —global_arithработает с_ENVбез metatable, null-check уже short-circuit'ит). - P15.37d —
tools/perf_compare.py+ baseline. Скрипт собирает ReleaseFast-бинарь и PUC Lua reference, pinned на одно ядро черезtaskset -c 0, берёт медиану 7 прогонов на 16 workloads, печатает таблицуPUC | Zig | Zig/PUCс geomean, сравнивает с versioned baseline (tools/perf/baseline-p15.37.json, WARN >5%, FAIL >10%), опционально запускаетperf stat. Флаги:--update-baseline,--perf,--runs,--core,--no-build.
Итог P15.37: geomean 5.21× → 4.77× (8.5% improvement). Цель ≤3× не
достигнута — основные оставшиеся hotspot'ы: instruction-count bound dispatch
(branch_loop/comparisons ~98% в runBytecodeDispatch) и nodeLookup
(~28% на global_arith). Оба требуют codegen-level работы (P15.38+):
variant TKey для полной Node parity, fewer opcodes per Lua iteration.
Performance patch принимается только вместе с correctness gates:
zig build test -Doptimize=Debug;- differential smoke;
- 29/29 portable/soft matrix;
- direct
gc.lua; gengc.lua --testc;- direct
constructs.luaиverybig.lua; - iterative-dispatch stress под 1-МБ host stack.
PUC Lua оптимизировался десятилетиями, поэтому обещать 1.0× без изменения codegen, layout и dispatch неправильно. Текущие milestones:
- После P15.31: геометрическое среднее <8×. ✅ (достигнуто)
- После P15.32–P15.33: геометрическое среднее <3×. ❌ (достигнуто 5.21×)
- После P15.34–P15.35: геометрическое среднее <2×. ❌ (не достигнуто)
- После P15.37: геометрическое среднее ≤3×. ❌ (достигнуто 4.77×)
- После P15.38a–h: геометрическое среднее ≤3×. ❌ (достигнуто 3.43×)
- Затем отдельные workloads доводятся до parity или небольшого выигрыша.
Оставшиеся hotspot'ы после P15.37 (требуют codegen-level работы, P15.38+):
- Instruction-count bound dispatch (
branch_loop/comparisons~98% вrunBytecodeDispatch, IPC=4.24, branch-misses ≤0.02%) — CPU выполняет инструкции быстро, но их слишком много. Нужно: fewer opcodes per Lua iteration (codegen improvements), compact dispatch table. nodeLookup~28% наglobal_arith— Node 48 B vs PUC 24 B. Полная parity требует variant TKey (int/str/etc packed в 8 байт).
Общий уровень PUC требует одновременно трёх архитектурных изменений:
- меньше bytecode-инструкций;
- компактный common-case dispatch;
- PUC-подобная плотность tables, GC objects и call frames.
Цель: уменьшить число bytecode-инструкций на Lua-итерацию через PUC 5.5
codegen fast paths. Каждая подзадача устраняет 1–3 инструкции в common-case
паттернах (s = s + 1, if a < b then, x = x + 1.0).
- P15.38a — GETTABUP/SETTABUP metatable fast path. Глобальные
чтения/запись через
_ENVupvalue проверяютflagsbitmask передgetFieldOpt(__index/__newindex). Устраняет 2 из 3nodeLookupвызовов на каждый global access. Результат: global_arith 3.187→1.552s (-51.3%). - P15.38b — VJMP conditional expressions.
genComparisonExpвозвращает VJMP ExpDesc (CMP+JMP, 2 инструкции) вместо материализации boolean (5 инструкций).genIf/genWhile/genRepeatиспользуют VJMP напрямую черезgoIfTrue/goIfFalse.and/orиспользуют jump-list concatenation. Результат: branch_loop 2.576→1.442s (-44.0%), comparisons 4.112→1.566s (-61.9%). - P15.38c — Direct-store to locals.
genBinOpпринимаетdst_hint: ?u8;genAssignдляlocal s; s = s + iпередаёт регистрsкак hint, эмитяADD s, s, i(1 instr) вместоADD tmp, s, i; MOVE s, tmp(2 instr). Результат: int_arith 0.841→0.515s (-38.7%). - P15.38d — Immediate comparison opcodes (EQI/LTI/LEI/GTI/GEI/EQK).
6 новых опкодов сравнивают R[A] с signed immediate (sB) или constant
pool entry (K[B]), устраняя предшествующий LOADI/LOADK. Codegen:
genComparisonExpпринимаетrhs_const: ?NumConst; когда RHS — малая целая константа, эмиттится immediate variant. VM: Int/Num fast path + metamethod fallback (materialized imm). Результат: comparisons 1.928→1.566s (cumulative -61.9% vs P15.37), geomean 4.77×→3.69×. - P15.38e — Eliminate PendingCallSlot.get() 760-byte copy.
PendingCallSlot.get()returnedBytecodePendingCallby value, copying the 760-byte payload on every call — the source ofcompiler_rt.memcpyhotspot (10% of cycles onlua_calls). Changedget()to return?*const BytecodePendingCall(pointer). Theset()copy remains (changing it caused code-layout regressions onfield_access/global_arith). Результат: lua_calls 0.615→0.596s (-3%), memcpy 10%→<1% of cycles. - P15.38f — Debug hook fast path (PUC
allowhook/hookmaskmodel). Eliminated 8.4% debug hook overhead onlua_callswhen no hooks are active. Two changes mirroring PUC Lua's debug hook architecture: (1)hooks_active_cachedearly-return at the top ofdebugDispatchHookTransfer,debugDispatchHookWithCalleeTransfer,tryPushBytecodeDebugHook, andhasActiveHookEvent— one bool read instead of linear frame search +std.mem.eqlstring compare (PUC'shookmaskcheck); (2) movedin_debug_hookfrom Vm (global) toDebugHookState(per-thread, like PUC'sL->allowhookonlua_State), with both sync and async hook paths setting/clearing it.isInDebugHook()simplified from O(n) linear search (activeAsyncDebugHookFrame) to O(1) per-thread flag read. Per-thread storage means coroutine yield/resume automatically switches to the correct thread's flag — no recompute on thread switch. Результат: lua_calls 6.53×→5.95× (-8.9%), geomean 3.74×→3.71×. - P15.38g — GETTABLE/SETTABLE metatable fast path.
GETTABLE/SETTABLE were missing the
metatable == nullfast path that GETI/GETFIELD and SETI/SETFIELD already have. Without it, GETTABLE always calledtryPushBytecodeIndexMetamethod(does arawGetto check key existence) +bytecodeIndexValue(does anotherrawGet) — a double lookup. Now checksobj.Table.metatable == nullfirst and does a singlerawGet/rawSet, matching PUC'sluaV_gettablefast path. Результат: array_access 6.62×→4.33× (-34.6%), hash_access 7.71×→5.25× (-31.9%), geomean 3.74×→3.53×. - P15.38h — tostring() stack-buffer fast path.
Added
valueToInternedStr()which writes Int/Num directly to a stack buffer and interns it, bypassing the heap allocation invalueToStringAlloc+internStr. Mirrors PUC'sluaO_tostring(lobject.c:464):char buff[LUA_N2SBUFFSZ]; luaO_tostringbuff(obj, buff); luaS_newlstr(L, buff, len). Falls back to heap alloc for non-numeric types. Результат: string_loop 3.91×→3.52× (-9.9%), geomean 3.53×→3.43×. - P15.38i — PUC-style builtin results on bc_stack (eliminate dupe).
Architectural change: OP_CALL handler now writes builtin results directly
to bc_stack (value stack) above the arguments, matching PUC Lua's model
where C functions write results to
L->top.p. Eliminates theouts_smalllocal buffer, thealloc.dupe+alloc.freefor debug hook transfer values on every builtin call (~14% of hash_access cycles), and the separate copy from outs to regs.callBuiltintracks whether outs points into bc_stack (for futurerefreshBuiltinOutsre-derive in builtins with re-entry — PUC's savestack/restorestack equivalent). Debug hook only dupes whenhooks_active_cached(slow path). OP_TAILCALL and OP_TFORCALL still use local buffers (their dupe is for return-value ownership, not debug hooks). Результат: dupe/free eliminated from perf profile. hash_access -4.6%, array_access -4.4%, geomean 3.43×→3.42×.
Итог P15.38 (a–i): geomean 4.77× → 3.49× (-26.8% slowdown).
Цель ≤3× почти достигнута — оставшиеся hotspot'ы: lua_calls (6.0×,
call frame overhead), hash_access (5.2×, Node 48B vs 24B),
field_access (4.7×, table lookup). Дальнейший прогресс требует
variant TKey (P15.39+) и call frame compaction.
Архитектурная PUC-faithful компрессия ltable.Node с 48 B до 32 B через
разделение key: Value (16 B tagged union) на 1 B type tag (key_tt) +
8 B raw payload (key_val), и удаление кэшированного hash: u64 поля
(хеш пересчитывается на каждом use site, как в PUC ltable.c).
Field layout (32 B total):
value: Value(16 B) — full tagged valuekey_val: NodeKeyPayload(8 B) — bare extern unionnext_offset: i32(4 B) — Brent chain linkkey_tt: NodeKeyTag(1 B) + 3 B padding
Cache-line density: 64 B line × 32 B node = 2 full nodes per line
(было 1 при 48 B). Это качественный порог — дальнейшее сжатие до 24 B
(полная PUC parity) дало бы только partial третьего node и требует
изменения самого типа Value (отдельная задача P15.40+).
Architectural changes:
NodeKeyTag = enum(u8)с 9 вариантами (empty,dead,int,num,string,table,closure,thread,bool_).emptyмаркирует свободный slot,dead— dead key (PUCLUA_TDEADKEY).NodeKeyPayload = extern union— tagless 8-byte union mirroring PUCunion Value(lobject.h:49).Node.getKey()/setKey()bridge methods reconstruct/store a fullValuefrom/to the split tag+payload representation. Используются только в холодных путях (GC, итерация) — горячий путь lookup'а используетNode.keyMatches()(см. пункт 7).Node.rawHash(seed)recomputes the hash fromkey_tt+key_valat each use site — PUCltable.cdoes the same via inlinehashint/hashstr/hashpointer/hashbooleancalls.- Dead keys marked by
key_tt = .deadinstead of a high bit inhash(PUCLUA_TDEADKEYmodel). Payload cleared to sever stale pointers. keyHash(.Num)теперь вызываетhashNum(раньше возвращал 0 для float keys — нарушение Brent invariant после swap, исправлено отдельно).Node.keyMatches(Value)(добавлен в рамках Task 6 regression fix) — PUC-faithful inlinekeyeqбез reconstruction полногоValue. РаньшеnodeLookupзвалkeyEq(n.getKey(), key): switch поkey_ttдля сборки 16-байтногоValue+ switch по тегу вkeyEq. Теперь один switch сравнивает tag и payload на месте, как макросkeyeqв PUC.
Migration strategy (6 tasks):
- Task 1: Add new types + accessor methods (no layout change)
- Tasks 2-4: Migrate ltable.zig / vm.zig / api.zig callers to accessors (transitional bridge writes keep both representations in sync)
- Task 5: Physical layout swap (drop
key/hashfields, drop bridge writes) - Task 6: Full regression + perf measurement + README update + Node.keyMatches fix для восстановления PUC-faithful горячего пути lookup'а
Результат (median of 7, microbench, vs tools/perf/baseline-p15.37.json):
| Workload | До P15.39 | После P15.39 | Изменение |
|---|---|---|---|
| hash_access | 5.22× | 5.04× | -3.8% |
| field_access | 4.71× | 5.08× | +6.4% (WARN) |
| global_arith | 3.32× | 3.48× | +2.9% |
| array_access | 4.28× | 4.19× | -1.6% |
| lua_calls | 6.01× | 6.02× | +0.3% |
| geomean | 3.49× | 3.47× | -0.6% |
Microbench geomean нейтрален: cache плотность 32 B node не помогает
tiny tables (поля table field_access/global_arith — 2-3 ключа, полностью
в L1). Regression на field_access — архитектурная цена switch dispatch
в keyMatches против прямого чтения Value (однако +10.6% → +6.4% после
добавления inline keyMatches).
Cache плотность раскрывается на big tables (>L2):
| Workload (100K-key hash, 20M lookups) | До P15.39 | После P15.39 | Изменение |
|---|---|---|---|
| Wall time | 1.87 s | 1.60 s | -14.4% |
| LLC cache-misses | 10.1 M | 1.7 M | -83.2% |
| L1-dcache-load-misses | 45.9 M | 27.1 M | -41.0% |
| Cycles (core) | 7.16 B | 6.06 B | -15.3% |
Parity preserved: 28/31 matrix pass без _soft/_port (без новых failures
сверх известных attrib/big/files; db/nextvar теперь pass после
follow-up фикса Builtin-ключей — см. ниже). 44/44 smoke tests pass. Stress test pass.
Follow-up fix (Builtin keys). Оригинальный P15.39 ошибочно считал, что
Builtinне может быть ключом таблицы, иNode.setKeyмаппил.Builtin→key_tt = .empty, тихо теряя ключ (нарушение PUC Lua: C-функции являются легитимными ключами, хешируются по identity). Это ломалоdb.lua/nextvar.lua(используют[print]/[assert]/[checkerror]как ключи). Follow-up коммит добавилNodeKeyTag.builtin+NodeKeyPayload.builtin: BuiltinId(BuiltinId явноenum(u8)чтобы жить вextern union), и провёл через всех пяти switch-сайтов:setKey/getKey/rawHash/keyMatches/keyHash. Hash использует@intFromEnum(builtin_id)— PUC-analoghashpointerдля C-функций.@sizeOf(Node) == 32сохранён. Parity: 26/31 → 28/31 (db+nextvar восстановлены).
Инлайнинг PUC luaD_precall (ldo.c:715-746) в три горячих bytecode-handler'а
(OP_CALL, OP_TAILCALL, OP_TFORCALL). Fast path (Closure/Builtin) больше не
вызывает resolveCallable — type switch происходит inline, нулевой overhead.
__call metamethod resolution использует in-place stack shift (PUC tryfuncTM,
ldo.c:523-536) вместо heap allocation Value[args.len + 1].
Что изменилось:
tryCallMetamethodInPlace— новый метод на Vm, PUCtryfuncTMequivalent. Shiftsbc_stack[a..a+nargs+1]up by 1 slot, writes metamethod toregs[a].- OP_CALL: inline type switch +
tryCallMetamethodInPlace. EliminatesresolveCallablecall,ResolvedCallstruct,defer owned_args,rargsderivation block, two-phase error retry, heap alloc for__call. - OP_TAILCALL: same inline pattern. Frame-reuse path simplified — always
uses
copyForwards(noowned_argsbranching). - OP_TFORCALL: same inline pattern + eliminates
alloc.dupe(resolved.args).
resolveCallable остаётся для 25 холодных сайтов (hooks, builtins, metamethods,
coroutine.resume, apiCall, IR backend). Эти сайты конструируют args в локальных
массивах, не на bc_stack — heap alloc для __call там малый и не влияет на perf.
Результат (median of 7, ReleaseFast, vs tools/perf/baseline-p15.37.json):
| Workload | До P15.35 | После P15.35 | Изменение |
|---|---|---|---|
| lua_calls | 0.564 s (6.01×) | 0.474 s (5.03×) | -15.9% time |
| metamethod_add | 0.263 s (4.49×) | 0.208 s (3.47×) | -20.9% time |
| geomean | 3.49× | 3.23× | -7.4% |
lua_calls improvement (-15.9%) в ожидаемом диапазоне 10-20%: fast path больше
не делает function call (resolveCallable) + struct alloc + defer. metamethod_add
улучшение (-20.9%) превзошло ожидания 5-10% — тот же call machinery, и __call
больше не heap-allocs. Остальные workloads ±noise (unrelated to call resolution).
Parity preserved: 28/31 matrix pass без _soft/_port (без новых failures
сверх известных attrib/big/files). 45/45 smoke tests pass (добавлен
tests/smoke/45_p15_35_call_metamethod_inline.lua — покрывает OP_CALL,
OP_TAILCALL, OP_TFORCALL через __call). Stress test pass. Baseline updated.
Pre-allocation bytecode_frames и frames ArrayList capacity (64 entries) при
активации thread (activateRuntime), создании main thread (init) и создании
coroutine (apiNewThread). Первые 64 addOne вызова на каждом thread теперь
pure items.len += 1 — без capacity-check branch на hot path.
PUC Lua не имеет этого overhead вообще (linked list, luaE_extendCI heap-alloc
но никогда не освобождает на return). Наш ArrayList с pre-allocation — это
промежуточный шаг; полная parity требует Phase B (merge frames) и Phase C
(inline array в Thread).
Результат: lua_calls 0.474→0.484 s (+2.1%, в пределах noise). Ожидаемо — capacity-check branch уже хорошо предсказывался branch predictor'ом. Реальный win ожидается от Phase B (merge frames) и Phase C (inline array). Parity: 28/31 matrix, 45/45 smoke tests, stress test pass.
Определён merged CallFrame struct (PUC CallInfo equivalent), объединяющий
поля BytecodeExecFrame + RuntimeFrame в одну структуру. proto: ?*const bc.Proto
(null для IR frames, non-null для bytecode frames) — дискриминатор, как PUC's
CIST_C bit.
P15.40b-full (Tasks 1–7): Полный merge dual-array pattern завершён.
pushBytecodeExecFrame делает ОДИН addOne to Thread.call_frames с ALL полями.
Больше нет second addOne to Vm.call_frames для runtime copy. Поле
runtime_frame_index полностью удалено.
Архитектурные изменения:
pushBytecodeExecFrame: singleaddOne+ unified field writespopBytecodeExecFrame: single pop fromThread.call_framesparkActiveRuntime: sync topmost bytecode frame'spcfrombc_dispatch_pcbefore parking (fixes stalepc=0on coroutine yield)callBuiltin: syncpc/current_lineonThread.call_framessyncTopFrameForGc: syncThread.call_framesfail()/setOutOfMemoryError(): checkThread.call_framesfirsterrorLocationFrameIndex: returns?*const CallFrame, walks both arraysenterProtectedCFrame: records combined depth (bytecode + IR)debugResolveFrameIndex: returns?*CallFrame, walks both arraysdebugInferNameFromCaller: takes?*const CallFrameinstead ofusizecurrentVisibleFrameDepth/snapshotThreadTraceFrames: walks both arraysdebugBuildCurrentTraceback: walks both arrays (uses*const CallFramelist)threadCurrentParkedRuntimeFrame: checksth.call_framesactiveAsyncDebugHookFrame: walksThread.call_framesbuiltinCoroutineYield: reads fromThread.call_frames- GC Thread propagation: scans
regs/boxed/callee/env_overrideonth.call_frames - GC
gcMarkMutableRoots: scansThread.call_framesfor bytecode frames ensureBcStackCap: fixes upThread.call_framesafter reallocbcGrowFrame: updatesThread.call_framestopmost frame- Dispatch loop defer block: syncs
regs/boxedto merged frame parkBytecodeIrHookYield: takesexec_frames+frame_indexruntime_frame_indexfield: removed from both structs
Результат: Build PASS, Smoke 45/45, Matrix 28/31 (no regressions).
runBytecodeDispatch изначально содержал все opcode handlers inline в одном
огромном switch (~3500 строк, 79 opcode'ов). Zig Debug выделяет стек-слоты
под ALL locals во ALL ветках switch — без liveness analysis между ветками.
Результат: C-stack frame = 139 KB в Debug (20 KB в ReleaseFast).
Архитектурный fix (PUC-faithful: PUC luaV_execute имеет отдельные функции
для complex opcodes — luaV_concat, luaV_setlist, luaV_arith):
BytecodeDispatchCtxstruct (~120 B) — все 15 dispatch state fields (regs, boxed, pc, frame_cap, base, cur_proto и т.д.) в одном объектеDispatchResultenum:continue_dispatch/continue_no_advance/continue_frame_loop/return_results/propagate_error- Извлечены 11 handlers как методы:
opCall,opTailcall,opConcat,opForprep,opTforcall,opClosure,opVararg,opReturn*,opSetlist - 68 small handlers (1–2 строки) остались inline
Результат: dispatcher frame = 54 KB (Debug) / 18 KB (ReleaseFast). Smoke 45/45, matrix 25/31 (no regressions).
Опробован переход с iterative dispatch на host recursion для OP_CALL
(эквивалент PUC luaD_call → ccall → luaV_execute). Идея была в том,
что host recursion устранит PendingCallSlot целиком.
Discovery: проверка исходника PUC Lua 5.5.0 (lvm.c:1731) показала, что
PUC ИТЕРАТИВЕН для Lua-to-Lua вызовов:
vmcase(OP_CALL) {
...
if ((newci = luaD_precall(L, ra, nresults)) == NULL)
updatetrap(ci);
else { /* Lua call: run function in this same C frame */
ci = newci;
goto startfunc; // ITERATIVE — не рекурсия!
}
}Host recursion в PUC происходит только для C↔Lua переходов (luaD_call из C host code). Существующий iterative dispatch в luazig уже PUC-faithful. Изменения откачены.
У PendingCallSlot было 768 B из-за inline optional storage больших
variant'ов BytecodePendingCompletion:
| Variant | Size | Use case |
|---|---|---|
gsub |
~120 B | string.gsub с function replacement |
hook |
~50 B | debug hooks |
close |
~60 B | TBC closers (__close) |
concat |
~40 B | OP_CONCAT с __concat metamethod chain |
coroutine_resume |
~40 B | coroutine.resume |
BytecodeProtectedCall (protection field) |
376 B | pcall/xpcall |
Все эти variants редкие (string.gsub с функцией, debug hooks, TBC variables,
pcall/coroutine — не в hot path). Inline storage раздувало union для
common case (results = 16 B).
Fix: heap-allocate большие variants, оставив inline только results/value/
ignore/compare (1–16 B каждый):
const BytecodePendingCompletion = union(enum) {
results: BytecodeResultContinuation, // 16 B inline — common case
value: BytecodeValueContinuation, // 1 B inline
ignore: BytecodeIgnoreContinuation, // 0 B inline
compare: BytecodeCompareContinuation, // 1 B inline
concat: *BytecodeConcatContinuation, // 8 B ptr — heap
gsub: *BytecodeGsubContinuation, // 8 B ptr — heap
hook: *BytecodeHookContinuation, // 8 B ptr — heap
close: *BytecodeCloseContinuation, // 8 B ptr — heap
coroutine_resume: *BytecodeCoroutineContinuation, // 8 B ptr — heap
};Аналогично protection: ?*BytecodeProtectedCall вместо inline optional.
Результат:
| Struct | Before | After |
|---|---|---|
BytecodePendingCompletion union |
152 B | 24 B |
BytecodePendingCall |
760 B | 48 B |
PendingCallSlot |
768 B | 56 B ✓ matches PUC CallInfo (~80 B) |
CallFrame |
~1200 B | 424 B ✓ matches Phase C goal (~430 B) |
Heap allocation overhead платится только для редких фичей; common path (OP_CALL с result continuation) полностью inline. Smoke 45/45, matrix 25/31.
Коммит 89a7d70 (merge bytecode frames в Thread.call_frames) сломал
sync bc_dispatch_pc во время error recovery. callBuiltin и fail()
безусловно синхронизировали bc_dispatch_pc в topmost frame, но во время
error recovery (xpcall handler) bc_dispatch_pc указывает на failed
child's pc, а не на parent's. Sync перезатирал parent frame's pc.
Fix: bc_dispatch_active flag (set inside runBytecodeDispatch, cleared
by defer). callBuiltin и fail() проверяют flag перед sync — если
dispatch уже вышел, pc stale и sync пропускается.
Root cause: switchRuntime переключает runtime с main thread на coroutine,
parkActiveRuntime правильно синхронизирует bc_dispatch_pc в main thread's
frame, но activateRuntime НЕ обновляет bc_dispatch_pc для нового thread.
Stale pc от previous thread's dispatcher остаётся.
Когда callBuiltin(.pcall) вызывается на coroutine, он синхронизирует stale
bc_dispatch_pc (e.g. 25, main thread's OP_CALL для coroutine.resume) в
coroutine's topmost bytecode frame, перезатирая правильный pc (e.g. 6, foo's
OP_CALL для coroutine.yield).
Это ломает resume: opCall's resumed_direct_yield check
(ctx.pc == ctx.resume_pc) fails потому что ctx.pc = 25 вместо 6.
Coroutine body никогда не возобновляется из yield; pcall возвращает
true,nil вместо true,42.
Fix: в activateRuntime, load bc_dispatch_pc из activated thread's topmost
bytecode frame. Это гарантирует что callBuiltin's sync записывает правильное
значение обратно.
Results: coroutine.lua passes. Matrix 25/31 → 26/31. errors.lua и locals.lua всё ещё fail из-за traceback quality (xpcall error handler видит unwound stack — separate pre-existing issue).
captureErrorTraceback ходил только по Vm.call_frames (IR frames), пропуская
bytecode frames в Thread.call_frames. После P15.40b-full (merge bytecode frames
в Thread.call_frames) error tracebacks были почти пустыми для bytecode closures
— xpcall error handlers видели только [C]: in function pcall вместо полного
call stack.
Fix: walk Thread.call_frames first (most recent), then Vm.call_frames,
collecting *const CallFrame pointers from both arrays (same pattern as
debugBuildCurrentTraceback).
Также fix builtinAssert: когда assert(false) вызывается из bytecode closure,
проверял Vm.call_frames (empty) вместо Thread.call_frames, выдавая
assertion failed! без source location. Теперь проверяет Thread.call_frames
first, matching PUC's file.lua:line: assertion failed! format.
Results: errors.lua passes. Matrix 26/31 → 27/31. locals.lua gets further (fails at 'to-be-closed variables in coroutines' — deep coroutine+close+yield interaction, separate issue).
opReturn, opReturn1 (both paths), and opTailcall allocated a []Value
slice for return values and passed it to beginBytecodeClose as
.return_frame = ret. The close continuation stored this slice in
close_state.post.return_frame. However, each function had an
errdefer if (ret_owned) self.alloc.free(ret); that fired when
beginBytecodeClose returned error.Yield (which happens when a __close
metamethod yields via coroutine.yield). This freed the slice while the close
continuation still held a dangling pointer to it.
On resume, the allocator had reused the freed memory for coroutine.yield's
yielded-values array (th.yielded), so close_state.post.return_frame[0]
contained a pointer to the yield array instead of the original return value.
This caused locals.lua line 926 ("yielding inside closing metamethods while
returning") to fail: the table returned after resume was a different object
than the one yielded.
Fix: set ret_owned = false before calling beginBytecodeClose, transferring
ownership of the slice to the close continuation. The continuation's
freeBytecodeClosePost / cancelBytecodePendingCall already handles freeing
the slice when the close completes or is cancelled.
Results: locals.lua passes. Matrix 27/31 → 28/31 (parity restored).
Embed a fixed-size [32]CallFrame array directly in Thread, eliminating
heap allocation for call chains ≤32 deep (the vast majority of real Lua
programs). Deeper chains spill to a heap ArrayList overflow.
PUC Lua's base_ci is the equivalent inline first frame; we inline 32
because our CallFrame (424 B) is larger than PUC's CallInfo (~48 B),
and typical Lua call depth is well under 32.
FrameStack wrapper: inline_frames[32] + heap: ArrayList overflow.
All access goes through getPtr(index) / getConstPtr(index) / len() /
addOne() / shrinkTo(). 42 function signatures migrated from
*std.ArrayListUnmanaged(CallFrame) to *FrameStack. ~211 direct
.items[] indexing sites migrated to the wrapper API.
Thread.call_frames (bytecode frames) stays with the thread during coroutine
switch — no special handling needed (unlike Vm.call_frames IR frames which
are moved via parked_call_frames).
Results: Parity 28/31 (no regressions), smoke 45/45. geomean 3.22×, lua_calls 0.393s (-17.1% vs baseline). No perf regressions.
Eliminate alloc.dupe(Value, varargs_src) on every vararg function call by
storing varargs directly on bc_stack below the register window (PUC's
buildhiddenargs model). CallFrame.varargs (heap slice) is replaced by
nextraargs: u16 for bytecode frames; varargs are accessed via
bc_stack[base - nextraargs .. base].
IR frames keep heap varargs (frame.varargs slice) — only bytecode frames
use the new model.
Key changes:
pushBytecodeExecFrame: write varargs at[bc_stack_top .. bc_stack_top + nextra], setbase = bc_stack_top + nextra. No heap allocation.popBytecodeExecFrame: restorebc_stack_top = base - nextraargs. No heap free.opVararg/OP_VARARGPREP: derive varargs slice frombc_stack[base-nextra..base].opTailcall: whennew_nextra > old_nextra, shift register window UP by the delta; copycall_argsto a stack-local buffer first to handle overlap.opCall/opTailcallpre-grow: account forchild_nextrainensureBcStackCap.- GC mark: scan
bc_stack[base-nextra..base]for bytecode frame varargs. - Debug API:
frameVarargs()helper derives slice from bc_stack for bytecode frames, falls back toframe.varargsfor IR frames.
Results: Parity 28/31 (no regressions), smoke 45/45. geomean 3.25×, lua_calls 0.426s (-10.2% vs baseline).
Цель: закрыть главный parity/perf-блокер — nextvar.lua (~511× медленнее ref).
Дизайн (PUC-first): единый Table (array-part + hash-part с Brent's variation
chaining, см. lua-5.5.0/src/ltable.c:13-24) вместо текущих 4 карт, плюс
интернирование строк (аналог lstring.c). Строковые ключи сравниваются по
указателю на интернированную LuaString.
Зафиксированные отступления от PUC (Zig-идиомы):
-
Value— taggedunion(enum)вместоTValue(tag + union). -
Node.next_offset—i32signed index offset (PUCgnext),dead_keyупакован в старший битhash(P15.37b). Node = 48 B (было 56 B при?*Node+bool). -
lastfree— обычное поле наTable, а не C-хакLimboxперед массивом Node. -
Phase A: интернирование строк.
Value.String→*LuaString(header + inline bytes + cached hash).StringInternHashSet на Vm с per-VM seed. Полная PUC string-lifecycle модель (вскрыта при отладке, изначальный план «интернировать всё» был неполным): short (≤LUAI_MAXSHORTLEN=40) → глобальный intern, pointer-eq; long runtime (rep/concat/format) → свежая аллокация, content-eq; long литералы → compile-time dedup (отдельнаяlong_literalsтаблица);string.gsubвозвращает входной объект при отсутствии замен. Равенство —luaStringEq(lvm.c:600-624 + lstring.c:44-50). Чекпоинт: паритет 33/34,zig_fail=0,zig build testgreen,memerr.luagreen. Отставания: GC sweep intern-таблиц и удалениеconst_stringsвынесены в отдельные чекбоксы ниже (причины зафиксированы вdocs/superpowers/plans/2026-07-05-phase-a-string-interning.md). -
Phase B1: инкапсулировать
Tableза внутренним API. Выполнено в рамках B2 (swap сделан напрямую через новый API, отдельная B1-стадия не потребовалась). -
Phase B2: swap
Tableна PUC array+hash.array: []Value+hash: []Node- Brent chaining (
src/lua/ltable.zig). Линейныйnext(),luaS_eqstr-стиль равенства,value:=Nildelete (без tombstones). Удаленыnext_hint_*(7 полей),nextFrom*/nextFirstLive*(~10 функций),hash_tombstones,PtrKey, 4 карты. Паритет: все 10 canary suite green (nextvar/sort/tpack/locals/calls/strings/ db/gc/pm/literals); net −293 строк машинерии. Perf (честно): на Debugnextvarпочти не сдвинулся (33s→29.5s) — bottleneck не таблицы, а debug-overhead + IR-VM interpreter. На ReleaseFastnextvar=1.48s (~23× от ref) — реальный оставшийся gap = скорость IR-VM (отдельная работа).computesizes(оптимальный array-sizing) не портирован — future optimization.
- Brent chaining (
Generational mode больше не является compatibility-веткой, запускающей полный
incremental cycle на каждый collectgarbage("step"). Реализована отдельная
PUC-подобная young-generation модель поверх per-type registry luazig:
- Все управляемые GC-типы (
Table,Closure,Thread,Cell, managedLuaString) хранят возрастNEW/SURVIVAL/OLD0/OLD1/OLD/TOUCHED1/TOUCHED2и стабильный registry index. Удаление из registry использует swap-remove с обновлением индекса перемещённого объекта. - Nursery хранится в отдельных per-type списках;
OLD1, rememberedgrayagain, remembered cells и old threads имеют собственные очереди. Minor collection обходит young snapshots и эти очереди, а не весь old heap. - Forward barrier продвигает young child старого closure/metatable/upvalue в
OLD0; backward barrier переводит изменённый old owner вTOUCHED1 → TOUCHED2 → OLD. Barrier paths покрывают table writes, cells, closure environment, upvalue join/close и смену metatable. - Пережившие minor cycles переходят
NEW → SURVIVAL → OLD1 → OLD. Weak tables, ephemerons, finalizable objects и threads обрабатываются с учётом поколения; старый weak container не удерживает недостижимый young value. - Накопленные bytes, ставшие old, переключают collector из minor mode в
persistent incremental major cycle по
minormajor; после major он возвращается в minor mode поmajorminorлибо продолжает incremental major cycles. Автоматический minor pacing используетminormul; все три параметра доступны через Lua 5.5collectgarbage("param", ...). - TestC
T.gcageчитает реальные возраста объектов, аT.codeparam/T.applyparamиспользуют Lua 5.5 floating-byte encoding. Поэтому полныйgengc.lua --testcпроверяет upstream age/barrier transitions, а не подставленные ожидаемые строки.
Постоянный differential regression
tests/smoke/43_generational_minor.lua проверяет old→young barrier, вложенный
young graph, очистку young value из old weak table и параметры режима. Zig
unit tests дополнительно проверяют, что minor collection не сканирует
512-объектный old graph, и принудительный переход minor → major → minor.
Подтверждённые проверки на Zig 0.16.0:
zig build -Doptimize=Debugиzig build test -Doptimize=Debug— PASS;- differential smoke — 44/44 PASS;
- iterative dispatch stress под 1-МБ host stack — PASS;
- заявленная portable/soft upstream matrix — 29/29 PASS;
gengc.lua --testc—OK;- portable/soft
gc.lua— 2.08 с Debug / 0.51 с ReleaseFast; - direct
constructs.luaбез_soft— 53.94 с,OK; - direct
verybig.luaбез_soft— 6.93 с,OK.
Архитектура не копирует intrusive GCObject.next списки PUC побайтово:
luazig сохраняет отдельные массивы указателей. Но поколения, remembered-set
инварианты, minor/major transitions и наблюдаемая Lua/TestC-семантика
соответствуют модели Lua 5.5.
Прямой upstream gc.lua без _port/_soft выявил ошибку pacing, которую не
покрывала portable matrix. collectgarbage("restart") только включал флаг
collector, сохраняя старый post-cycle threshold; после достижения threshold
automatic GC выполнял лишь 64 object-work units раз на 20 000 table
allocations. В table-heavy цикле mutator поэтому создавал мусор быстрее sweep и
проверка pace после long strings не завершалась.
Исправление следует модели PUC Lua:
restartсбрасывает allocation debt: следующая аллокация сразу получает возможность продвинуть GC;- automatic incremental slice вычисляется из
stepsize × stepmul; коэффициент 20 компенсирует различие work units — PUC sweep обрабатывает до 20 объектов за один sweep step, тогда какgcAdvanceluazig считает каждый объект отдельно; - smoke
44_gc_restart_pace.luaостанавливает collector, накапливает garbage, включает его обратно и проверяет, что heap возвращается к live baseline, а не растёт без ограничения.
После hotfix: Debug build/unit tests, 44/44 differential smoke, 1-МБ dispatch
stress и gengc.lua --testc проходят; direct gc.lua завершается с OK
примерно за 5.4 с в текущем Debug-окружении.
-
IR VM заморожена, bc=default. Default backend переведён на
--vm=bc. IR VM (--vm=ir) сохранена как debug fallback, parity не поддерживается.run_tests.pyиtestes_matrix.pyявно используют--vm=bc.release_gate.shгоняет bc (большинство suites failing — прогресс-трекер).zig build testпродолжает гонять IR unit-тесты (они тестируют shared runtime). -
Perf: IR-VM interpreter speed — закрыто как устаревшая цель. IR VM заморожена и не является parity/perf target; профилирование bc_vm остаётся отдельным открытым пунктом после достижения parity.
-
GC sweep-pass: реализовать настоящий mark+sweep для всех объектов (tables/closures/threads/strings/cells).
gcCycleFullтеперь выполняет полный mark+sweep на всех точках: explicitcollectgarbage(), tick-trigger (каждые 20000 инструкций), и allocation-trigger (через Handle API / temp roots). Все типы объектов sweep'ятся (P15.0–P15.7).GC Phase (завершена): per-type
ArrayList(*T)-реестры на Vm заменяют PUC's intrusiveGCObject.next-list (нулевая модификация layout типов, overhead идентичен — 1 указатель/объект). План:- GC registry infrastructure —
gc_tables/gc_closures/gc_threads/gc_cells/gc_strings; hook'нуты все 18 сайтов аллокаций (4 Table + 6 Closure + 4 Thread + 3 Cell + 1 runtime-long-string).Vm.deinit_drain'ит реестры как единственная точка владения для уничтожения объектов. Поведение неизменно; gc/gengc/tracegc + 8 canaries green. - Root-set completion + Table sweep —
gcMarkVmRootsдобавляет VM-level metatables/threads/registries в корни; frame marking расширен (all locals, boxed cells, callee, env_override).gcSweepTablesс in-place compaction + snapshot boundary. Sweep активен только на safe points (explicitcollectgarbage()) и вне debug hooks. Ограничение: regs не mark'ятся (нет register-top tracking → нельзя sweep mid-expression); register-top planned для следующей итерации. - Closure/Thread/Cell sweep —
gcSweepClosures/gcSweepThreadsс той же compaction+snapshot pattern. Closure: destroy struct только (upvalues ownership ambiguous). Thread:freeThreadWrapBuffers+ aux free + destroy. Cell sweep отложен (требуетmarked_cellstracking). gc/gengc/tracegc/api/coroutine/db/nextvar + 8 canaries green. - Register-top tracking (live_regs) — backward liveness analysis
в codegen (
ir.computeLiveRegs): per-PC bitset живых регистров (fixpoint iteration для loops). GC mark'ит только live регистры черезlive_regs[pc * num_values + reg]. Включает tick-trigger sweep (между инструкциями builtins уже вернулись, регистры точно track'ятся). Allocation-trigger sweep остаётсяdo_sweep=false(Zig locals внутри builtins невидимы GC). Cell sweep и string sweep отложены. - Real memory accounting —
gc_count_kbcharge на alloc (@sizeOf(Type)для Table/Closure/Thread/Cell/String), discharge на sweep (actual bytes freed). Удалён фейкgc_count_kb = 0.0reset.collectgarbage("count")возвращает реальный размер. - String sweep —
gcSweepStringsдля runtime long strings (gc_stringsregistry). Mark phase traverse'итValue.Stringчерез worklist (.Stringcase вgcMarkValue). String keys in hash nodes coordinated with dead-key handling (PUC-likeDEADKEYmodel). Source strings fromload(string)pinned как GC roots (pinned_source_strings). Long literals sweep'ятся черезlong_literals.sweep(). Short strings (string_intern) сейчас снова НЕ sweep'ятся — см. ограничение ниже. - Cell sweep —
gc_marked_cellsset (Vm-level, какgc_marked_strings). Mark'ится при traversal closures' upvalues и frames' boxed/upvalues.gcSweepCellsfrees unmarked cells. - Long literal sweep —
StringIntern.sweepдляlong_literals: удаляет unreachable entries из intern table. Short strings (string_intern) остаются pinned/eternal до появления Proto-owned constant roots.
Оставшиеся ограничения:
- Short string intern sweep временно отключён.
string_intern.sweep()был включён в P15.8, ноall.luaпоказал UAF после последовательностиgc.lua -> db.lua: debug hook вызываетcollectgarbage(), затемstring.gsubполучает pattern какValue.String, указывающий на уже освобождённую short string. Это не проблемаgsubкак такового, а отсутствие PUC-инварианта: в PUC Lua строковые константы являются частьюProto->kи mark'ятся как GC roots. У нас IR пока хранит string lexemes как slices/source references и материализуетConstStringлениво черезinternStr/internLiteral; не все такие materialized constants имеют стабильного владельца, который GC гарантированно mark'ит через debug-hook/continuation edges. Поэтому short strings пока pinned как глобальный intern table. - Чтобы честно включить
string_intern.sweep()снова, нужно перейти к PUC-like ownership для строковых констант: при load/codegen создать decoded constant pool (*LuaString) внутри IR/Proto/function object, mark'ить этот pool в GC traversal для Closure/Function/Proto roots, убрать ленивое decode/intern из hot execution path, и только после этого разрешить sweep short-string intern table. Критерий включения:all.lua,gc.lua,db.lua,strings.lua,coroutine.lua,calls.luaи full matrix проходят сstring_intern.sweep()включённым. Allocation-trigger sweep disabled— закрыто в P15.7: Handle API (temp roots) защищает Zig-local temporaries в builtins;allocTableself-protect'ит return value; 4 CRITICAL multi-alloc site'а защищены (ensureDebugRegistry, builtinTestcMakeCfunc, pushcclosure, builtinDebugGetinfo); dead registers очищаются перед sweep (предотвращает dangling pointers в debug.getlocal).— закрыто в P15.7: sweep больше не подавляется внутри debug hooks;in_debug_hookguarddebug_transfer_valuesявно mark'ятся вgcMarkVmRoots.- Short strings eternal / pinned — снова открыто после P15.8 audit:
err_obj+gmatch_statemark'ятся корректно, но этого недостаточно. Не хватает Proto-owned decoded string constant roots; поэтомуstring_intern.sweep()отключён до архитектурного шага с constant pool. - Real memory accounting — закрыто в P15.4:
gc_count_kbcharge/discharge на alloc/sweep. - String sweep — закрыто в P15.5:
gcSweepStrings+gcMarkValuetraverseValue.String; координация сstring_intern/long_literals.
- GC registry infrastructure —
-
Убрать
const_strings/internConstString— закрыто в P15.8: 32 call site'а мигрированы наinternStr/internLiteral; третий string store удалён. -
Закрыть
heavy.luamemory/perf gap в рамках P15.34/P15.37, не отдельным benchmark-specific хаком. -
Профилировать bc_vm после достижения parity — выполнен свежий ReleaseFast профиль P15.30.1; baseline и bottlenecks зафиксированы выше.
-
Развивать публичный Zig embedding API после стабилизации bc_vm.
-
Держать README, release gate и perf baselines актуальными после текущей GC/string фазы.
-
Закрыть verifier follow-up P15.28: direct
constructs.lua<60 с, 29/29 matrix, short-string GC debt и generational step regression.
- Убрать отладочный
*.lua-мусор в корне репо (debug_special_case.lua,final_*.lua,isolate_failure.luaи т.п.) —debug_special_caseнарушает запрет AGENTS.md наspecial_case_*. - Запушить локальные коммиты в
origin/master.
-
P3: стабилизация базы до API; targeted parity suite,
bc_vmcoverage gate, perf guard и runtime invariant audit. -
P4: начальный публичный Zig API и базовый C ABI shim.
-
P5:
testC/ltestscompatibility до прохожденияapi.lua --testc. -
P6: official
testClane; missing commands сведены к нулю. -
P7: расширение Zig/C-like API для
testC, genericT.testCкоманды переведены на API-входы. -
P8: базовая official suite compatibility до
33/34 pass parity,zig_fail=0. -
P9: публичный Zig embedding API отделён от VM internals.
-
P10: readiness report, release gate, честная классификация blockers.
-
P11: OOM/error-object fixes и первые PUC-first perf/memory шаги.
-
P12: full migration на актуальный system Zig и успешный release gate на system toolchain.
-
P13: интернирование строк (Phase A) —
Value.String→*LuaString, полная PUC short/long/literal семантика,luaStringEq, gsub-reuse. Паритет 33/34 сохранён. -
P14: PUC-faithful Table (Phase B) — единый array+hash с Brent chaining (
ltable.zig), удалены 4 карты/next_hint_*/tombstones (−293 строк). Паритет canaries green. Perf-цельnextvar ≥10×на Debug не достигнута: реальный bottleneck — debug-overhead + IR-VM interpreter (RF nextvar=1.48s, ~23× от ref). -
P15.0: GC registry infrastructure — per-type
ArrayList(*T)-реестры на Vm (gc_tables/gc_closures/gc_threads/gc_cells/gc_strings); hook'нуто 18 сайтов аллокаций;Vm.deinitdrain'ит реестры (единственная точка владения). Replaces PUC intrusiveGCObject.next-list без модификации layout типов. gc/gengc/tracegc + 8 canaries green. -
P15.1: GC root-set completion + Table sweep —
gcMarkVmRoots(metatables, threads, dump_registry, debug_upvalue_ids); expanded frame marking (all locals, boxed cells, callee, env_override);gcSweepTablesс in-place compaction + snapshot boundary. Sweep только на safe points (explicitcollectgarbage(), вне debug hooks). gc/gengc/tracegc/api/coroutine/db/nextvar + 8 canaries green. -
P15.2: GC Closure/Thread sweep —
gcSweepClosures(destroy struct, upvalues not freed),gcSweepThreads(freeThreadWrapBuffers + destroy). Cell sweep deferred (marked_cells tracking needed). Same safe-point constraints. All 15 suites green. -
P15.3: Register-top tracking —
ir.computeLiveRegs(backward liveness, fixpoint for loops, per-PC bitset).Frame.pcupdated in dispatch loop. GC marks only live registers vialive_regs[pc*nv+reg]. Enables tick-trigger sweep (between instructions). Allocation-trigger sweep stays disabled (Zig locals invisible). All 15 suites green. -
P15.4: Real memory accounting —
gc_count_kbcharged on alloc (@sizeOf(Type)for Table/Closure/Thread/Cell/String), discharged on sweep (actual bytes). Removed fake= 0.0reset. Strings not charged (sweep deferred). All 15 suites green. -
P15.5: String sweep —
gcSweepStringsfor runtime long strings.gcMarkValuetraversesValue.Stringvia worklist. String keys in hash nodes conservatively marked (keyEq dereferences). Source strings fromload(string)pinned as roots. Short strings / long literals remain eternal. All 15 suites green. -
P15.6: Cell sweep + long literal sweep —
gc_marked_cellsset (marked during closure/frame traversal).gcSweepCellsfrees unmarked cells.StringIntern.sweepremoves unreachable long literals from intern table. Short strings remain eternal. All 15 suites green. -
P15.7: Handle API (temp roots) — allocation-trigger sweep enabled,
in_debug_hookguard removed.gc_temp_roots: ArrayList(Value)+TempRootsscope helper (snapshot/restore, analog of PUC LuaL->stackfor Zig locals). GC mark phase traverses temp roots +debug_transfer_valuesingcMarkVmRoots. 4 CRITICAL multi-alloc sites protected (ensureDebugRegistry, builtinTestcMakeCfunc, pushcclosure, builtinDebugGetinfo).allocTableself-protects return value. Dead registers cleared before sweep (prevents dangling pointers in debug.getlocal's for-state detection). All 15 suites green. -
P15.8:
const_stringsremoval + short-string sweep attempt —const_strings/internConstString/internConstStringMaybeOwnedfully removed; 32 call sites migrated tointernStr/internLiteral; third parallel string store eliminated.err_obj+gmatch_state.{s,p}marked as GC roots. Follow-up all.lua audit found that enablingstring_intern.sweep()is premature without Proto-owned decoded constant roots; short-string sweep is disabled again, while long literal sweep remains enabled. ReleaseFast matrix after fix: 32/33 pass parity,zig_fail=0, onlyheavy.luaboth-fail timeout. Speed checkpoint: full matrix 3:34.71 wall;all.luaRF 5.16s vs PUC 0.416s (~12.4x);nextvar.luaRF 1.342s vs PUC 0.038s (~35x). -
P15.9: bc_vm weak table pruning —
codegen_bc.resetRegsтеперь используетpeak_freereg(high-water mark регистра за statement) вместоfreereg, чтобы LOADNIL покрывал все временные регистры включая аргументы CALL и темпы от конструирования таблиц. РаньшеgenCallуменьшалfreeregдоfunc_regпосле 0-result CALL, иresetRegsне очищал регистры аргументов — stale pointer'ы выживали GC и блокировали pruning weak table entries.peak_freeregобновляется вreserveRegsи в прямых присваиванияхfreereg(method call self-reg, vararg explist). gc.lua "weak tables" section проходит. All 15 suites green. -
P15.10:
local _ENVshadowing в codegen —local _ENV = ...теперь корректно затеняет_ENVupvalue для своего scope, как в PUC Luasinglevar(). Раньше все global-доступы (read/write, global decls, global func decl, assignment) всегда шли черезensureEnvUpvalue()+GETTABUP/SETTABUP, игнорируя локальный_ENV. ДобавленыemitGlobalGet/emitGlobalSet: если_ENVразрешается как local, индексируется регистр (GETFIELD/SETTABLE, сGETTABLE/SETTABLEfallback для kid>255), иначе — старый upvalue-путь. Все 6 global-access сайтов переведены на хелперы. Тестlocal _ENV <const> = 11; X = "hi"→attempt to index a number valueпроходит. 16 parity suites green. -
P15.11:
errors.luaparity для bytecode VM — strippedstring.dump(f, true)теперь сохраняет исполняемыйProtograph и удаляет только debug metadata; bytecode dispatch обновляетFrame.current_line, аdebug.getinfo(..., "l")использует bytecode lineinfo без IR/path compensation. Арифметические type errors приведены к PUC-формеattempt to perform arithmetic on a <type> value. Добавлен differential smoke для stripped round-trip, nested Proto/upvalue, error text и current line.errors.luaи все smoke tests проходят. -
P15.12:
coroutine.luaparity для bytecode VM — bytecode frames сохраняют continuation state и TBC-регистры черезyield/resume; аварийное и принудительное закрытие выполняет все__closeв LIFO-порядке с передачей последнего error object; возвраты и tail calls закрывают живые TBC slots; call/line/return hooks сохраняют позицию через suspension.coroutine_resumeдобавлен вbuiltinHasDynamicOutCount— fix утечки nil в vararg-контекстах.luazig.zig: thread spawn 256MB stack вместо setrlimit. Добавлены differential smoke тесты (25, 26).coroutine.luaпроходит; project matrix — 24/29. Текущая архитектура host-recursive dispatch loop — технический долг, см. TODO выше. -
P15.13: iterative bytecode call frames, этап 1 — введён явный стек
BytecodeExecFrame; обычные Lua-to-LuaOP_CALLpush'ят дочерний frame без рекурсивного вызова Zig-функции, аOP_RETURN*pop'ят его и продолжают родителя. Изначально owner'ом списка был отдельный вызовrunBytecode; P15.21 переносит ownership вThread. Yield/error unwind проходит explicit frame stack сверху вниз и сохраняет совместимость с текущими coroutine snapshots. Добавлен smoke27_iterative_bytecode_calls.lua(350 non-tail calls). Подтверждено, чтоpcall/xpcall, metamethod и nested-resume re-entry остаются следующим этапом; 256MB stack пока не удалён. Unit tests, 27/27 smoke,calls.lua,coroutine.lua,errors.luaи прежние parity suites проходят. -
P15.14: fixed-width multi-results from method calls —
genMethodCallтеперь обновляетfreeregпо фактическомуnresults, как обычныйgenCall. Раньше assignment в уже объявленные переменные (a, b = object:method()) после корректногоCALL C>1затирал результаты со второго onward инструкциямиLOADNIL. Добавлен differential smoke28_method_call_fixed_multiret.lua;files.luaпроходит targeted parity. -
P15.15: native platform I/O — добавлены
io.popen/pclose,os.execute/os.exit, PUC-compatible process result triples, последовательный pipe I/O черезstd.processиstd.Io,arg[-1]для пути интерпретатора, POSIX%Ex/%Oyи проверки representable range вos.date/os.time. Добавлен differential smoke29_platform_process_io.lua. Portable matrix (_port=true) дляfiles.luaпроходит идентично PUC Lua. Non-portable section (без_port=true) имеет известный platform-dependent failure на line 814 (sh -c 'kill -s HUP $$'— ожидается"exit", возвращается"signal"), одинаковый с PUC Lua на этой системе. Общий счёт остаётся 25/29. -
P15.16:
locals.luaparity для bytecode VM —LocVarхранит точный register/range, поэтомуdebug.getinfoразличает реальные bytecode closures вместо общегоbc_dummy_func; OP_TBC валидирует non-closable значения при активации и сообщает имя local; return hooks больше не дублируются. Yielding error-unwind сохраняет последний error object между__close, аreturn f()с живым TBC компилируется как CALL+RETURN, не TAILCALL. Forwardgotoтеперь резервирует patchable CLOSE и при выходе из scope учитывает скрытый generic-for TBC slotbase+3, предотвращая stale TBC register после nested-loop goto. Добавлен differential smoke30_locals_tbc_unwind.lua;locals.luaпроходит, project matrix — 26/29. -
P15.17:
db.luaparity для bytecode VM — debug metadata теперь использует точныеlastlinedefined/active-line ranges, call-operand name inference и реальные bytecode closures, включая main chunk. Varargs вынесены из расширяемого register frame в отдельное владение кадра, поэтому multiret/grow больше не повреждаетdebug.getlocal(..., -n). Реализованы count hooks на bytecode dispatch, suspended-coroutinegetinfo/getlocal/setlocal, transfer metadata,for iterator/metamethod names и nil-line hook для stripped chunks. Исправлен debug temporary scan для frame >256 регистров. Добавлен differential smoke31_debug_bytecode_parity.lua;db.luaиconstructs.luaпроходят, project matrix — 28/29. -
P15.18: debug LocVar cleanup — generic-for и numeric-for записывают PUC-совместимые скрытые
"(for state)"slots вProto.locvarsна этапе bytecode codegen, устраняя потребность в runtime-эвристике. Bytecodegetlocal/setlocalиспользуют реальные LocVar range/register metadata. Для count-hook compatibility filters (IR и bytecode) добавлен TODO с критерием удаления и дедлайном до 1.0.0. Добавлен smoke32_for_loop_locvars.lua. -
P15.19: PUC-faithful
os.clock— удалён deterministic stub, всегда возвращавший0.0.os.clock()теперь читает process CPU clock черезstd.Io.Clock.cpu_process, соответствуя семантике Cclock()в PUC Lua без libc fallback. Добавлен differential smoke33_os_clock_cpu_time.lua;sort.luaпечатает реальное время сортировки. -
P15.20:
gc.luaparity — автоматический bytecode GC tick теперь уважаетcollectgarbage("stop"); explicitcollect/stepработают при остановленном сборщике, не меняя running-state. Step использует memory-sized work budget (gcStepBudgetBytes), поэтому большийnзавершает цикл за меньшее число вызовов. Полныйgc.luaпобайтно совпадает с PUC Lua. Текущий step scheduler завершает mark/sweep атомарно после исчерпания budget; TODOgc-incremental-phasesтребует заменить его настоящими persistent mark/propagate/sweep phases до 1.0.0. Добавлен smoke34_gc_stop_and_step.lua. Project matrix — 29/29. -
P15.21: thread-owned bytecode frame stack —
BytecodeExecFrameиBytecodePendingCallвынесены в thread-level runtime model, а authoritative descriptor stack перенесён из локальной переменнойrunBytecodeвThread.bytecode_frames. Каждый вход вrunBytecodeфиксирует boundary depth и на return/error освобождает только свой suffix; это сохраняет один owner при re-entry черезpcall/xpcall, metamethod и nestedcoroutine.resume. LegacySuspendedFramesnapshots пока сохранены, поэтому основной TODO не закрыт: следующий этап — per-thread register/TBC ownership и resume непосредственно из сохранённого frame stack. Добавлен smoke35_thread_owned_bytecode_frames.lua; unit tests, 35/35 smoke, ключевые parity suites и recursion guard проходят. -
P15.22: thread-owned runtime stacks — runtime
Frame, bytecode register/boxed buffers, stack top и TBC list перенесены вThread. VM держит только borrowed hot-path view активного потока;coroutine.resumeпаркует caller и активирует callee move-only операциями без копирования. GC traversal mark'ит parked runtime stacks и frame upvalues, поэтому nested coroutine может запустить sweep без потери значений caller'а. Добавлен smoke36_thread_owned_runtime_stacks.lua; unit tests, 36/36 smoke и ключевые parity suites проходят. Попытка сразу оставить live frames на месте при yield выявила обязательный следующий шаг: yielding__close/metamethod path всё ещё требует explicit dispatch continuation, поэтому legacySuspendedFrameпока не удалён. ReleaseFast snapshot после parity: nextvar 1.37s, coroutine 0.12s, gc 1.12s в текущем окружении. -
P15.23: direct bytecode yield continuation — обычный
OP_CALL/OP_TAILCALLкcoroutine.yieldна корневой границе bytecode dispatch оставляетBytecodeExecFrame, register/boxed buffers и TBC list в thread-owned storage вместо копирования вSuspendedFrame.resumeпродолжает тот же parked frame и записывает resume values в исходный CALL; debug API читает parked runtime frame, а GC/teardown сохраняют его как обычный Thread root. Архитектурный unit test проверяет, что после yieldsuspended_framesпуст и authoritativebytecode_framesостаётся жив; smoke37_inplace_bytecode_yield.luaпокрывает nested/tail yield, debug inspection, GC и abandoned coroutine. Compatibility boundary имеет TODO удаления до 1.0.0; snapshots остаются только для re-entrant protected/metamethod/hook/__closepaths. -
P15.24: iterative protected dispatch — bytecode targets
pcall/xpcallи bytecode error handlers больше не вызывают вложенныйrunBytecode. Protected state (saved error, handler phase/depth and result continuation) хранится вBytecodePendingCall; общий dispatch loop ловит runtime error, unwind'ит только failed child suffix и продолжает ближайший protected caller. Yield внутри protected call сохраняет тот же thread-owned frame stack.TFORCALLк bytecode-итератору также переведён на explicit child frame. Teardown abandoned coroutine освобождает parked protected state без подмены текущей ошибки VM. Исправлена обнаруженная этим переходом dangling-ссылка в builtinerror(): fallback теперь копирует сообщение в стабильный VM buffer. Smoke38_iterative_protected_dispatch.luaпокрывает глубокие обычные и tail-positionpcall/xpcall, yield/resume, Lua iterator и сборку abandoned protected coroutines; explicit protected depth принадлежит конкретномуThread, поэтому parked coroutines не делят глобальный лимит. Unit tests, 38/38 smoke иcalls.luaс 1-МБ host stack проходят. Открыты metamethod/hook/__close/nested-resume actions. -
P15.25: complete iterative bytecode dispatch — Lua metamethods, debug hooks, yielding
__close, nestedcoroutine.resumeиstring.gsubLua callbacks переведены наBytecodePendingCompletion/thread-owned continuations. Persistent unwind продолжает close chain после yield и ошибок; coroutine trampoline переключает parkedThreadчерезThreadSwitchбез Zig recursion. Bytecode snapshot/replay удалён;IrSuspendedFrameостался только у frozen IR compatibility children и связан с bc caller через explicit pending continuation. Удалён 256-МБ interpreter thread stack.hasActiveClose()синхронизирует tail-call policy с PUC Lua для named и generic-for TBC slots. Добавлены smoke39_complete_iterative_dispatch.luaи 1-МБ stress lane. Unit tests, 39/39 smoke, полныйcstack.luaпод 1-МБ stack и 29/29 upstream suites по exit/assertion status проходят. -
P15.26: dispatch review hardening — повторно подтверждён PUC-order hidden generic-for TBC (
returnexpression вычисляется до iterator__close, результатfalse true);ThreadSwitchудалён из публичногоVm.Errorи локализован в privateDispatchError/coroutine trampoline;api.zigбольше не знает о внутреннем сигнале. Рядом с pending continuation state документированы ownership, cleanup, yield и thread-switch invariants. После рефакторинга повторно проходят unit tests, 39/39 smoke, 1-МБ stress lane и 29/29 upstream suites. -
P15.27: завершение regression cleanup после iterative dispatch — исправлены large
SETLIST + EXTRAARG, lexicalgoto/global-attribute/named-vararg semantics, source line tables и PUC-like line-hookoldpc; удалён trace preseed.saved_parent_calleedebug-hook continuation добавлен в GC roots, а strippedloadвсегда инициализирует первый environment upvalue. Расширен smoke31_debug_bytecode_parity.lua;40_large_setlist_extraarg.luaзакрепляетverybigcodegen. Подтверждены unit tests, 40/40 smoke, 1-МБ stress, 29/29 upstream matrix и directverybig.lua. -
P15.28: verifier follow-up — dynamic
load()/automatic-GC scheduling переведены с fixed instruction cadence на live-heap debt, короткие interned strings включены в memory accounting, а explicit generational step выполняет атомарный цикл с PUC-compatiblefalseresult.runBytecodeскрывает private dispatch signal, strict-global codegen не знает имён stdlib,time.txtсохраняется через snapshot/restore. Добавлен smoke42_gc_debt_and_generational_step.lua; подтверждены 42/42 smoke, 29/29 soft/portable matrix, directconstructs.lua<60 с и directverybig.lua. -
P15.29: persistent incremental GC — collector хранит
pause/propagate/atomic/sweepstate, gray queue, mark sets и sweep cursors междуcollectgarbage("step", n); mark/sweep расходуют реальный bounded budget, mutable roots и write barriers сохраняют tri-color invariant, а registry entries удаляются до освобождения объекта. Исправлены finalizer epoch/reentrancy и escaping main-chunk_ENVownership. Unit regression наблюдает отдельные phases и barrier writes. Подтверждены Debug build/tests, 42/42 smoke, 1-МБ stress, 29/29 upstream matrix, direct heavy tests иgc.lua1.85 с Debug / 0.54 с ReleaseFast. -
P15.30: настоящий generational GC — добавлены PUC-like object ages, per-type nursery, OLD1/remembered/old-thread queues, forward/back barriers и young-only sweep. Minor collections больше не вызывают полный incremental cycle и не обходят обычный old heap;
minormul/minormajor/majorminorуправляют minor → incremental major → minor transitions.gengc.lua --testcчитает реальные age states; добавлен smoke43_generational_minor.lua. Hotfix P15.30.1 сбрасывает automatic-GC debt приrestart, масштабирует automatic slice по PUC sweep granularity и добавляет44_gc_restart_pace.lua; directgc.luaбез_softснова завершается. Подтверждены 44/44 smoke, 29/29 matrix, GC suites, 1-МБ stress и direct heavy tests. -
P15.37a: frame-struct slim —
BytecodeExecFrame(936 B) иBytecodePendingCall(768 B optional) переведены на reuse-pool pattern (addOne+ field-by-field writes) +PendingCallSlotwrapper (active: bool+payload = undefined,clear()= single-byte write), устраняяcompiler_rt.memset(byte-wise zero-init структур фреймов, 57% от lua_calls) на каждом call/return. lua_calls 1.760→0.617s (-65%), metamethod_add 0.456→0.282s (-38%), memset share 53%→<1%. -
P15.37b: Node compaction —
ltable.Nodeуплотнён с 56 B до 48 B:next: ?*Node(8 B + padding) заменён наnext_offset: i32(4 B) PUCgnext-style signed index offset, аdead_key: boolупакован в старший битhash(DEAD_KEY_FLAG). Hash маскируется (HASH_MASK) при записи, чтобы live-key хеши с установленным bit 63 не ложно определялись как dead.nextNodeиспользует direct pointer arithmetic (self + off * @sizeOf(Node)) для chain walk без восстановления индекса. Паритет 28/31 сохранён, 44/44 smoke проходят,nodeLookupperf share ~28% (без регрессии). -
P15.37c: Table.flags bitmask (BITRAS) — добавлен
flags: u8вTable(PUCltm.h:54): bit set = "metatable не имеет метаметода". Проверяется передgetFieldOpt(mt, "__index"/"__newindex")в 5 точках. Сбрасывается при new-key insertion вrawSet(PUCltable.c:1112). PUC-faithful metamethod cache. Доля metamethod-check упала с 11% до ~9% на global_arith (меньше ожидаемого — benchmark работает с_ENVбез metatable). -
P15.37d:
tools/perf_compare.py+tools/perf/baseline-p15.37.json— reproducible perf gate. Скрипт собирает ReleaseFast + PUC Lua, pinnedtaskset -c 0, медиана 7 прогонов на 16 workloads, таблица с geomean, regression check (WARN >5%, FAIL >10%),--update-baseline/--perf/--runs/--core/--no-build. Geomean P15.37: 4.77× (было 5.21× после P15.36). -
P15.48a–d: callinfo-parity — 4-фазный refactor модели call frames:
- Phase A: pre-allocate frame capacity (64 frames inline,
ensureTotalCapacityon activate). - Phase B: merge
BytecodeExecFrame+RuntimeFrameinto singleCallFrame(424 B), eliminating dual-stack sync overhead. - Phase C: inline
[32]CallFrame+ heap overflow (FrameStack), migrating 42 signatures from*ArrayListUnmanaged. - Phase D: varargs on
bc_stack(nextraargs: u16,[base-nextra..base]), eliminating per-callalloc.dupe. Geomean после всех фаз: 3.20×.
- Phase A: pre-allocate frame capacity (64 frames inline,
-
DispatchLoopSlim: dispatch loop overhead reduction:
EXTRA_MARGIN = 5pre-allocated per frame (matches PUCEXTRA_STACK = 5, lstate.h:142).bcGrowFrameis a no-op for typical multiret (≤5 values).- Stack realloc check simplified from 3-way comparison (ptr + boxed_ptr + frame_cap) to single
bc_stack.ptrcomparison.bc_stackandbc_boxedare always realloc'd together;bcGrowFrameupdatesctx.regs/ctx.boxeddirectly via out-parameters. - Geomean: 3.10×. Key wins: branch_loop -18.4%, comparisons -28.5%, lua_calls -17.1%.
-
fr.pc sole pc: eliminated
bc_dispatch_pcandbc_dispatch_activefrom Vm.ctx.fr: *CallFramepointer gives direct access tofr.pc(like PUC'sci->u.l.savedpc). Three copies of pc reduced to one.fail()andcallBuiltin()readfr.pcfrom topmost frame directly. Per-instructionbc_dispatch_pcstore andbc_dispatch_activestore eliminated (-2 cycles). Re-entrancy fromrequire/dofileis safe — eachrunBytecodeDispatchinvocation has its ownctx(Zig stack), nested calls don't touch parent frame pc. Hooks block keeps its ownvar fr(re-derived after hook-executed Lua code may reallocexec_framesheap). CLOSE handler double-increment fixed:continueBytecodeClosealready incrementsfr.pc; the handler's mirror was removed. Geomean: 3.12×. -
lineinfo cold path (commit
9ae3e43): Removed per-instructionlineinfo[pc]read from the fast path.current_lineis now re-derived lazily by cold-path sites:fail(),callBuiltin(),debug.getinfo,debug.sethook,tracebackFrameLabel, and the hook dispatch fallback. Fast path no longer touches lineinfo at all. Geomean: 3.09×. -
IR executor removal (commits
7a674a6,7a4b546,0c4f63d): Removed the deprecated IR executor entirely.runFunctionArgsWithUpvalues(~1023 lines),runFunction,runFunctionArgs, and 26 IR-specific helper functions (~696 lines) deleted.runClosuresimplified — always callsrunBytecodeInternal,is_tailcallparameter removed.compileTextChunkIR fallback removed;api.zigswitched tocodegen_bc.bootstrapTestcand testC load switched tocreateBytecodeChunkClosure. Unit tests switched fromCodegen+runFunctiontoCodegenBc+runBytecode. Disabledlower_ir.zigandbc_vm.zigfiles deleted, commented imports removed fromroot.zig. All closures now haveproto != null. The IR pipeline (codegen.zig,ir.zig) remains as a compilation stage and debug dump tool only — execution is bytecode-only (PUC-faithful). Geomean: 3.09× (parity baseline preserved: 28/31 matrix, 45/45 smoke). -
IR dead code cleanup (commit
beec04f): Removed all remaining IR executor dead code — 163 insertions, 1692 deletions. Removed:IrSuspendedFramestructThread.ir_suspended_frames(never appended to),Vm.call_frames+Thread.parked_call_frames(~63 refs, always empty),Closure.func+bc_dummy_func_global(always placeholder),CallFrame.func+CallFrame.locals+CallFrame.local_active(always empty),synthetic_env_slot(always false),tail_resume_func(zero refs),apiWrapFunction(zero callers), dead-branch calls to non-existentdebugGetLocalFromIrSuspendedFrame/SetLocal, dead IR else-branches in debug/getinfo/traceback paths, dead IR-era functions (debugFillInfoFromIrFunction,debugGetLocalNameFromFunction,isCloseLocalIdx,frameEnvValue,getNameInFrame,setNameInFrame).CallFrameaccessors (isVararg,lineDefined,sourceName,numParams) simplified fromif (proto) |p| ... else func.*to directproto.?.*(proto is always non-null). Geomean: 3.05× (parity preserved: 28/31 matrix, 45/45 smoke).
-
Inline + dedup: reduce function-call overhead (this session): Micro-optimizations to reduce per-call overhead in the bytecode VM dispatch loop:
- Removed duplicate stack-overflow check in
pushBytecodeExecFrame(two checks with same operands merged into one — the second check subsumes the first because it accounts fornextravarargs). - Marked
resolveProtoConstantsasinline— fast path is a single bool check (proto.constants_resolved), cold path does string interning. - Marked
popBytecodeExecFrameasinline— small function (2 branches, 3 field writes, 1shrinkTocall), avoids call overhead on every return. - Added
has_open_upvalues: booltoCallFrame— set byOP_CLOSUREwhen it captures a stack register into a Cell. All 6closeBytecodeUpvaluesFromcall sites now gate behindif (frame.has_open_upvalues), skipping the linear scan overframe_capslots when no upvalues are open (the common case). - Fixed stale "760 bytes" comments —
BytecodePendingCallis 48 bytes after P15.44 moved large continuation variants to heap pointers. Geomean: 2.95× (parity preserved: 28/31 matrix, 45/45 smoke).
- Removed duplicate stack-overflow check in
Детальная история оптимизаций, промежуточных замеров и закрытых подпунктов сохранена в Git (git log).