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 проходит 20/31 test suites (временно, см. ниже): api, attrib, bitwise, bwcoercion, calls, code, coroutine, errors, gengc, goto, literals, math, memerr, nextvar, pm, strings, tpack, tracegc, utf8, vararg, verybig. Матрица запускается с upstream portable/soft prelude _port=true; _soft=true.
Переход на PUC-faithful модель overlapping call stack, где каждый новый bytecode
frame начинается на позиции function register вызывающего (base = func_slot + 1,
PUC ci->func / ci->base = func+1), а не выше полного register window
вызывающего (base = bc_stack_top + nextra).
Что закрыто:
pushBytecodeExecFrame: new frame base =caller_func_slot + 1; аргументы уже на месте при OP_CALL — нет копирования аргументов. Для vararg-функций без named vararg table (PF_VAHID) реализован PUCbuildhiddenargs(ltm.c:255): func+params сдвигаются вверх past extra args, varargs остаются нижеci->funcat[func_slot - nextraargs .. func_slot].popBytecodeExecFrame: restorebc_stack_toptocaller.base + caller.frame_cap.frameVarargs/opVararg/getvarg: varargs at[func_slot - nextraargs .. func_slot].VARARGPREP: для VATAB extra args atbase + numparams(no shift); для VAHID ничего не делает.GETVARG: checks if vararg table was materialized (VATAB) — reads from table; otherwise reads from hidden args (VAHID).- OP_TFORCALL: PUC-faithful copy func+state+control ABOVE close value (R[A+4..]) before pushing callee frame — preserves TBC close value from overlapping.
- TAILCALL: resets to
func_slot_base(unshifted position) before buildhiddenargs — prevents cumulative shifting across repeated tail calls. shrinkBcStack: computes true high-water mark across ALL frames, not justbc_stack_top(overlapping model: top frame extent may be less than lower frames).- All
pushBytecodeExecFramecallers updated withcaller_func_slotargument. - GC varargs scan: both active-thread and parked-coroutine paths updated.
gcClearDeadFrameRegisters: bounded clear range to[clear_from, child.func_slot - frame.base)to prevent clobbering child frame registers in the overlapping model. Without this, clearing dead parent registers would nil out live child frame closures.
Результаты: 28/31 matrix suites проходят (1 zig_fail: code.lua — нужен fix codegen GETUPVAL→GETTABUP для глобальных, 2 both_fail: big.lua/files.lua — не связаны с этим изменением). 45/45 smoke tests проходят. cstack.lua и sort.lua проходят (были failing до этого изменения).
Производительность: geomean 2.82× vs PUC (улучшение с 2.86× baseline). lua_calls: -11.0%, field_access: -21.8%, comparisons: -13.4% — все быстрее. string_concat: +10% (borderline, в рамках шума benchmark harness).
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.
Реализована PUC constfolding (lcode.c:1418) в bytecode-компиляторе
(src/lua/codegen_bc.zig):
- Бинарные op'ы с двумя числовыми константами сворачиваются на этапе
компиляции:
3.78 / 4→0.945(одна константа, без runtimeOP_DIV). - Унарные op'ы (
-,~) на константном операнде сворачиваются. <const>locals с константным инициализатором подставляются по использованию (PUCRDKCTC/VCONST), в том числе через границы функций — value пробрасывается вconst_upvalue_valuesбез создания реального upvalue, как PUCsinglevarauxоставляетVCONST.- Safety guards как в PUC
validop: не сворачиваются DIV/IDIV/MOD на ноль (runtime error), bitwise на не-целых, и float-результаты NaN/0.0 (семантика-0.0). Арифметика (включая wrapping shifts, floor idiv/mod) зеркалитluaO_rawarith/intarith/numarith. - Попутно исправлена
luaK_float-parity: integer-valued floats (0.0,3.0) теперь используютLOADFвместоLOADK, не загрязняя constant pool.
Результаты:
code.lua --testc: folding-секция checkKlist/checkI/checkF (сворачивание в pool /LOADI/LOADF) проходит — все изолированные folding-кейсы верифицированы (3^-1,0xF0.0 | 0xCC.0 ~ 0xAA & 0xFD,~~-1024.0, и т.д.).- Регрессий нет:
tools/testes_matrix.py(без_soft/_port) — 29/29 pass (files.lua both_fail как раньше); всеtests/smoke/проходят.
Остающийся блокер для полного прохода code.lua --testc — pre-existing gap,
не связанный со сворачиванием: SETTABLE/SETI/SETFIELD value-операнд
кодируется через регистр, а не через RK (PUC exp2RK кладёт константу в pool).
luazig использует 8-bit opcode без spare K-bit, поэтому RK для SET требует
отдельных K-variant opcodes + VM support — это отдельная итерация.
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_compare.py— основной perf gate: 16 микро-бенчмарков, geomean Zig/PUC ratio.tools/perf_core_snapshot.py— end-to-end замер core suites (nextvar, coroutine, gc).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Основной инструмент — python3 tools/perf_compare.py:
16 микро-бенчмарков (tools/microbench.lua), median-of-7, pinned CPU core,
geomean Zig/PUC ratio, regression check vs tools/perf/baseline-p15.37.json.
python3 tools/perf_compare.py # run + compare vs baseline
python3 tools/perf_compare.py --no-build # skip rebuild (use existing binaries)
python3 tools/perf_compare.py --update-baseline # rewrite baselineТекущий geomean: 2.76× от PUC Lua. Без регрессий от baseline.
| Workload | Zig/PUC |
|---|---|
| string_concat | 1.56× |
| dynamic_load | 1.65× |
| comparisons | 1.75× |
| float_arith | 2.36× |
| int_arith | 2.61× |
| global_arith | 2.65× |
| branch_loop | 2.50× |
| field_access | 2.80× |
| temp_table_alloc | 2.96× |
| string_loop | 3.18× |
| mixed_arith | 3.26× |
| metamethod_add | 3.39× |
| lua_calls | 3.65× |
| coroutine_yield | 3.69× |
| array_access | 3.79× |
| hash_access | 4.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×.
После P15.42 (ctx.fr elimination, OP_MOVE @branchHint fix, stale frame.regs GC fix) — 2.88×.
После P15.43 (stale rargs GC corruption fix, OP_MOVE @branchHint fix, frame.regs GC fix) — 2.91×.
Сводная таблица по 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 отмечаются явно.
Цель: починить оставшиеся testC coroutine.lua failures. После P15.67
coroutine.lua падал на line 663 (setglobal X в line hook). P15.68 fixes
несколько связанных проблем:
-
P15.68a: Line hook
skip_bc_line_oncenot set on yield-from-hook. P15.67 fix only setresume_skip_count_pcfor count hooks. Line hooks didn't setskip_bc_line_once, so after resume the line hook immediately re-fired on the same instruction. Fix: setskip_bc_line_once = truefor line hooks inbuiltinCoroutineYield's P15.67 block. -
P15.68b:
sethooknot seedinglast_line_pcfor all Lua frames. Whensethookwas called fromT.sethook(a Lua function), only the top frame (T.sethook's frame) was seeded. The coroutine body's frame hadlast_line_pc = null, causing a spurious line hook on thesethookline. Fix: seedlast_line_pcfor ALL Lua frames on the call stack. -
P15.68c:
debug.getinfo(c, 1)returning wrong frame. For suspended coroutines with only one frame,debug.getinfo(c, 1)returned the same frame as level 0 instead of nil. Fix: checkth.call_frames.len() < 2. -
P15.68d: testC
yield(withoutk) not saving continuation. PUClua_yieldreturns on resume, andrunCreturns immediately. Our testCyieldcalledbuiltinCoroutineYieldwithout saving a continuation, so the coroutine body couldn't continue after yield. Fix: save areturn *continuation with empty stack (unless inside a debug hook). -
P15.68e:
builtinCoroutineResumerunning testC continuations directly. For bytecode-path yields (bytecode_inplace_suspended), the continuation must run inside the bytecode VM (which re-executes the OP_CALL tobuiltinTestcTestC). Fix: skip the direct continuation path whenbytecode_inplace_suspendedis true. -
P15.68f:
bytecode_inplace_suspendednot set for testC yields. Only debug hook yields setbytecode_inplace_suspended. testCyield(outside a hook) didn't set it, causingerrdeferto unwind all frames. Fix: setbytecode_inplace_suspended = trueinbuiltinCoroutineYieldwhentestc_pending_contsis non-empty and not in a debug hook. -
P15.68g:
debug.getinfowrong info for builtin-yield coroutines. When a coroutine yielded from a builtin (testCyield),debug.getinforeturned the Lua frame's info instead of the builtin's info (withlinedefined = -1). Fix: checksuspended_builtinandyielded_from_debug_hookto return the correct level 0 info.
Results: 8/9 testC suites pass. coroutine.lua fails at line 1093
(yieldk continuation test — separate issue). Matrix 28/31, smoke 45/45 —
no regressions.
Цель: починить chain of suspendable C calls в testC (T.makeCfunc с callk).
coroutine.lua падал на line 1191 — "chain of suspendable C calls" test.
Три уровня вложенных C-вызовов, каждый yield'ит через callk. При resume
ожидалось 3 значения 34 (одно от каждой continuation), но возвращалось 0.
- P15.69a:
saveTestcPendingContinuationsetsbytecode_inplace_suspended. Whencallk/pcallk/yieldkcatcheserror.YieldfromapiCall, the continuation is saved AFTERbuiltinCoroutineYieldreturns. Thebytecode_inplace_suspendedflag must be set here (not inbuiltinCoroutineYield) sorunBytecodeInternal's errdefer doesn't unwind frames. - P15.69b: Direct continuation path sets
resume_inbox. For Builtin callee coroutines (coroutine.wrap(T.testC)),setThreadResumeInboxmust be called beforeresumePendingTestcContinuationsotakeBytecodeResumeValuesreturns the correct resume args. - P15.69c:
builtinTestcTestCruns continuations regardless ofbytecode_inplace_suspended. Removed the!th.bytecode_inplace_suspendedcondition — continuations must run for BOTH direct and bytecode-inplace paths. - P15.69d:
pushupvalueindex Npushes integer N. PUClua_pushinteger(L1, lua_upvalueindex(N))pushes the upvalue INDEX, not the value. Used withcallk 1 -1 .where.pops this index. - P15.69e:
resolveTestcContinuationScriptfor.pops integer and fetches upvalue. PUC'sgetnum_aux(".")pops integer from stack, thenCfunckuseslua_tostring(L, ctx)to fetch the continuation script. - P15.70: LIFO continuation loop in
builtinTestcTestC. Multiple continuations from a chain ofcallkyields are run LIFO (innermost first), matching PUC'sunroll/finishCcall. Results of inner continuation feed as args to next-outer continuation.testc_pending_conts.pop()(LIFO) instead oforderedRemove(0)(FIFO). - P15.70:
bytecode_current_boundaryfield. Stores the boundary_depth of the currently runningrunBytecodeInternal. WhensaveTestcPendingContinuationsetsbytecode_inplace_suspended, it setsbytecode_resume_boundary = bytecode_current_boundary. Without this, nestedapiCall → runClosure → runBytecodeInternalcalls (e.g. testCcallcommand for selection functions) leave leftover frames that confuse resume. - P15.70: Errdefer unwinds nested frames on yield. The errdefer in
runBytecodeInternalusesis_suspension_ownercheck: the outermost call (boundary_depth == 0) and calls matchingbytecode_resume_boundarypreserve their frames; all others unwind. This correctly handles callk chains (f-closure frames unwound), toclose yields (__close frame preserved via yielded_in_place), and count hook yields (hook frame popped by builtinCoroutineYield, legitimate call frames preserved).
Results: 9/9 testC suites pass. coroutine.lua, locals.lua pass fully. Matrix 28/31, smoke 45/45 — no regressions.
Цель: расширить testC покрытие с 9 DEFAULT_SUITES до всех 31 suite в --testc режиме.
- T.listk(func): PUC ltests
listk— возвращает таблицу с константами функции (1-indexed). Реализован как builtintestc_listk, читающийproto.resolved_values. ВызываетresolveProtoConstantsесли константы ещё не разрешены. tests: calls.lua pass, code.lua продвинулся (нужен constant folding дляcheckKlist). - T.stacklevel(): PUC ltests
stacklevel— возвращает 5 значений: top (bc_stack_top), size (bc_stack.len), nCcalls (protected depth), nci (call_frames.len), addr (C stack address). Реализован как builtin с Lua wrapper для корректной multiret propagation черезselect(2, ...). - global reserved in testC mode: PUC Lua
LUA_COMPAT_GLOBAL—globalявляется reserved keyword в ltests режиме (без compat), и обычным именем в нормальном режиме (с compat). Lexer получил флагglobal_reserved, который устанавливается изVm.testc_module_enabled. Parser получил compat-обработку:.Name("global")промоутируется в.Globalкогда следующий токен указывает на global declaration (NAME,function,*,<attrib>).isNameTokenбольше не включает.Global—globalне может быть lvalue.
Results: testC matrix 27/31 pass (с 23/31). Выигрыш: calls.lua, goto.lua, cstack.lua, sort.lua pass. Matrix 27/31, smoke 45/45 — без регрессий.
Оставшиеся testC zig_fail:
code.lua— T.listcode реализован, но codegen генерирует GETUPVAL вместо GETTABUP для доступа к глобальным переменным (pre-existing codegen gap)attrib.lua— passes in testc mode (zig_only_pass); ref Lua lacks T modulebig.lua— pre-existing (yield from outside coroutine)files.lua— pre-existing (crash)
Цель: починить cstack.lua (3 части: stack overflow detection, message handling, stack recovery) и реализовать T.listcode для code.lua.
- cstack.lua Part 1 (overflow detection):
ensureBcStackCapне должен расти за пределы MAXSTACK (1000000). Строкаif (new_cap < needed) new_cap = neededпозволяла рост за MAXSTACK, ломая assertionstacknow == stack1. - cstack.lua Part 2 (message handling — xpcall(loop, loop)): Error
handler должен запускаться с ERRORSTACKSIZE headroom.
handling_overflowвpushBytecodeExecFrameиспользуетactiveErrorHandlerDepth()вместоself.in_error_handler(iterative dispatch используетbytecode_error_handler_depth, а неin_error_handler).startBytecodeXpcallHandlerустанавливаетbc_stack_top = MAXSTACKперед запуском handler.shrinkBcStackвызывается ПОСЛЕ handler (черезfinishBytecodeProtectedCall), а не перед. - cstack.lua Part 3 (stack recovery): После
xpcall(f, err)стек должен сжаться до MAXSTACK черезshrinkBcStackвfinishBytecodeProtectedCall.f()проверяетstacknow == stack1(оба MAXSTACK). - T.listcode: Реализован как
builtinTestcListcode— принимает Lua функцию, возвращает таблицу сmaxstack,numparams, и opcode-строками (формат PUC buildop).opcodeDisplayNameмаппит luazig Op → PUC имена. Остающийся блокер для code.lua: codegen генерирует GETUPVAL вместо GETTABUP для доступа к глобальным.
Цель: починить yield из count/line hook в testC режиме. Coroutine,
yield'ящая из hook'а (через T.sethook("yield 0", "", N)), не продолжала
выполнение при resume — error.Yield из async hook frame не очищал hook frame
и не устанавливал bytecode_inplace_suspended, что приводило к unwind всех
frames в errdefer и перезапуску coroutine с начала.
- Fix: Pop hook frame on yield from async debug hook. В
builtinCoroutineYield, когда yield происходит из async debug hook frame (count/line hook running as bytecode closure viatryPushBytecodeDebugHook), выполняется cleanup: pop hook frame, clean up parent'spending_call(freeBytecodeHookContinuation, restore callee/tailcall), setresume_skip_count_pc(for count hooks), clearin_debug_hook, setbytecode_inplace_suspended = true. Это mirrors PUC Lua'slua_yieldlongjmp behavior — C hook stack frame is abandoned. 8/9 testC suites pass (coroutine.lua fails at line 663 on unrelatedsetglobal Xline-hook argument issue, not yield-from-hook).
Цель: закрыть главный parity-блокер — nextvar.lua:41 (table rehash).
Реализуется PUC-faithful rehash algorithm (computesizes/numusearray/
numusehash/luaH_resize), заменяя eager array extension на PUC's
rehash-on-overflow model.
- Task 1: PUC rehash primitives in
ltable.zig. Добавлены pure functions:ceilLog2,MAXABITS/MAXASIZE,Counters,arrayIndex,countInt,numUseArray,numUseHash,arrayXhash,computeSizes. Все функции — pure (no VM coupling), оперируют на[]const Valueи[]const Node. 9 unit tests покрывают PUC reference values, включая критическийnextvar.lua:41scenario (keys 1-4 + 96-100 + 129 → asize=4). TDD: tests written first, verified RED, implemented, verified GREEN. - Task 2: Migrate
Table.arrayfromArrayListto[]Value+asize.Table.arraychanged fromstd.ArrayListUnmanaged(Value)to[]Valuewith explicitasize/lenhintfields, mirroring PUC Lua'sTablestruct (t->asize,*lenhint(t)). All 55.array.items/.array.capacity/.array.append/.array.ensureTotalCapacitycall sites updated to direct slice access +tableResizeArray/tableResizestubs.appendTableArrayValuekept temporarily (grows by 1, preserving old ArrayList append semantics forrawSetcontiguous extension).tableResizeArray/tableResizestubs added (replaced by PUC-faithful implementation in Task 3). GC size accounting, traversal, weak-value pruning,tableBorderLen,tableNext,rawGet,rawSet,builtinTestcQuerytab,builtinTableCreate,opSetlist, vararg table creation,makeLinesIterall updated. 28/31 matrix parity preserved (no regressions), 45/45 smoke tests pass. - Task 3: Implement
tableResize/tableRehashinvm.zig. Заменены временные stubstableResize/tableResizeArrayна PUC-faithful реализации.tableResize(PUCluaH_resize, ltable.c:716) выполняет joint array+hash resize: (1) создаёт новый hash part (power-of-2), (2) при shrink массива переносит vanishing slice keys в новый hash (PUCreinsertOldSlice+exchangehashpart), (3) выделяет новый массив, копирует общую часть, nil-fill новых слотов, (4) реинсертит старые hash-записи черезnodeInsert/array-routing (PUCreinserthash+luaH_newcheckedkey), (5) освобождает старые части, выставляетlenhint = new_asize / 2.tableResizeArrayделегирует вtableResizeс текущим размером hash (PUCluaH_resizearray). ДобавленtableRehash(PUCrehash, ltable.c:762): считает ключи (countInt/numUseHash/numUseArray), вычисляет оптимальные размеры черезcomputeSizes, добавляет +25% к hash size при наличии deleted entries.tableRehashпока не вызывается — подключение вrawSetэто Task 4. 28/31 matrix parity preserved (no regressions vs. Task 2 baseline), 45/45 smoke tests pass. - Task 4: Rewrite
rawSetto PUCluaH_newkeyflow. УдалёнappendTableArrayValue(eager array extension by 1).rawSetтеперь следует PUCluaH_newkey: (1)insertkey— попытка вставки в existing free slot, (2) при overflow →tableRehash(grow + redistribute keys between array/hash parts viacomputeSizes), (3)newcheckedkey— после rehash integer keys в array range идут в array part, остальные черезinsertkeyagain. Integer keys в[1..asize]идут напрямую в array part (PUCkeyinarrayfast path). Удалён eager contiguous extension (pull following hash keys into array) — теперь PUCcomputeSizesделает это при rehash.tableResizeиспользуетtestcChargeMemory(bytes + alloc count) вместоtestcConsumeAllocCount(только alloc count), чтобыtotalmemlimit в errors.lua:106 срабатывал корректно. Array reuse приnew_asize == old_asize(no allocation) — critical для testCalloccount. - Task 5: Implement PUC
luaH_getnfor length operator.tableBorderLenпереписан как PUCluaH_getn(ltable.c:1301): (1) binary search в array part, (2) еслиt[asize]present — exponential+binary search в hash part (hash_search, ltable.c:1239).hashIntIsPresentпроверяет hash part черезnodeLookup.hash_seedизменён с 0 на fixed non-zero (0x9E3779B97F4A7C15) — PUC randomizes seed для hash-flood protection иhash_searchrandomization; fixed non-zero seed deterministic (testC reproducibility) и предотвращает "attack on table length" (nextvar.lua). - Task 6: NEWTABLE hints +
checkTabArg+ testC fixes. Codegen backpatches NEWTABLE с computedna/nh(PUCluaK_settablesize):hsize_log2 = ceilLog2(nh)+1,asize = C + EXTRAARG*256. VM читает hints и вызываетtableResizeсразу (pre-size array+hash, no per-key rehash).varargpreppre-charge исправлен: только Table struct (array+hash charged bytableResize).checkTabArg(PUCchecktab, ltablib.c:47) реализован дляtable.insert/remove/move/concat/sort/unpack: принимает real tables OR non-tables с metatable + required metamethods (__index/__newindex/__len).__testc_nupvaluesfield added to distinguish actual upvalue count from oversized array. 8/8 testC suites pass (api, errors, gc, gengc, locals, memerr, nextvar, strings). Code review fixes:nupvaluesfield added toTestcPendingContinuation(was lost during yield/resume, causing upvalue access to return nil after coroutine resume);hsize_log2 > 31bounds check in OP_NEWTABLE (prevents panic on malformed bytecode).
Цель первого 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).
Bug fix (stale rargs in opCall/opTailcall):
opCall/opTailcall pre-grew bc_stack with child_frame_cap = p.maxstacksize,
but pushBytecodeExecFrame uses frame_cap = proto.maxstacksize + EXTRA_MARGIN (5).
When pushBytecodeExecFrame called ensureBcStackCap for the extra 5 slots, it could
realloc bc_stack, making the rargs slice (derived from ctx.regs before the call)
a dangling pointer. The child frame's register 0 would then contain garbage from freed
memory. This was a pre-existing nondeterministic crash (smoke 27, ASLR-dependent)
introduced in de34fc0 (ctx.fr elimination).
Fix: pre-grow with child_frame_cap = p.maxstacksize + EXTRA_MARGIN in both opCall
and opTailcall, so pushBytecodeExecFrame's ensureBcStackCap is always a no-op.
GC fix (stale frame.regs):
gcMarkMutableRoots, gcClearDeadFrameRegisters, and gcMarkVmRoots (inactive thread
path) used frame.regs which can become stale when GC finalizers execute Lua code that
reallocs bc_stack. Fixed to derive slices from self.bc_stack / th.bytecode_stack
directly, using frame.base and frame.regs.len (length is still valid even after realloc).
OP_MOVE @branchHint fix:
Removed @branchHint(.unlikely) from OP_MOVE fast path (no open upvalues). The fast
path is the overwhelmingly common case; marking it unlikely was a bug that pessimized
the hot path.
Results: smoke 27 crash eliminated (0/20), 45/45 smoke pass, 28/31 matrix, geomean 2.91× (was 2.88×).
Problem: The per-instruction GC tick in the dispatch loop added overhead on every
instruction, even when no allocation occurred. PUC Lua instead triggers GC only at
allocation sites via luaC_condGC(L, c) (called from luaM_*, OP_CONCAT, OP_CLOSURE,
and builtin calls).
Changes:
-
Removed per-instruction GC tick from the dispatch loop. GC is now triggered only at allocation sites, matching PUC Lua's
checkGC(L, c)pattern. -
condGcFromDispatch(ctx)— PUCcheckGC(L, c)analogue, called atOP_CONCAT,OP_CLOSURE, and builtin call sites. Checksgc_step_debt_kband runs an automatic GC step if needed. -
gcNoteAlloc(bytes)— PUCluaM_*→GCdebt -= bytesanalogue. Called on every allocation site (table creation, closure creation, string allocation, thread creation). Decrementsgc_step_debt_kbsocondGcFromDispatchcan detect when GC should run. -
gcAutomaticBudget— PUC-faithful formula:stepsize_bytes / sizeof(*anyopaque) * stepmul / 100 * GCSWEEPMAX. Usessizeof(*anyopaque)(PUCsizeof(void*)) andGCSWEEPMAX=20(PUClgc.h). -
gcFullCollectionForUser— addedgcScheduleNextAutomaticCycle()+ debt reset, matching PUCfullincwhich callssetpauseto schedule the next automatic cycle. -
live_reg_top[pc]"after" boundary (P15.38): ChangedProtoBuilder.emit()to recordcurrent_live_top(the "after" boundary) instead oflive_top_before(the "before" boundary). The "before" boundary (P15.36) caused GC to clear the destination register of allocation instructions (OP_CLOSURE, OP_CONCAT) when GC ran during the allocation (viagcNoteAlloc) but before the result was stored. The "after" boundary correctly preserves the destination register. -
genLocalDecllive_top sync: AddedsyncLiveTop()calls afternvarstackgrowth ingenLocalDeclandLocalFuncDecl.declareLocal, ensuringcurrent_live_toptracks all live locals at every PC. -
opReturn/opReturn0/opReturn1— syncfr.pc = ctx.pc; fr.reg_top = ctx.reg_top;beforebeginBytecodeCloseso GC sees correctlive_reg_top[pc]during__closefinalizers. -
OP_CALL — added
ctx.reg_top = @max(ctx.reg_top, a + nstore)for fixed nresults (was only for multi-returnnresults < 0). -
gcClearDeadFrameRegisters— useslive_reg_top[pc]+ TBC protection (doesn't clearbc_tbc_regsitems in top frame). -
gcMarkMutableRoots— marksregs[0..live_reg_top[pc]]+ separately marksbc_tbc_regsitems in top frame.
Results: 28/31 matrix (parity baseline maintained), 45/45 smoke pass, geomean 2.85×.
Problem: bcConstToValue (inline fn) was called on every OP_GETFIELD/OP_SETFIELD/
OP_GETTABUP/OP_SETTABUP/OP_LOADK execution — a 5-way switch on Constant tag to
reconstruct a Value (16 bytes). Perf showed 7.44% of field_access cycles in the
bcConstToValue inlining scope. PUC Lua stores TValue k[] in Proto (lobject.h:614) —
constants are already in runtime format, no per-execution conversion.
Fix: Added resolved_values: []Value field to Proto. resolveProtoConstants
(populated lazily on first execution) now pre-converts all Constant → Value into
this array. All 18 bcConstToValue(ctx.cur_proto.k[...]) call sites in the dispatch
loop replaced with ctx.cur_proto.resolved_values[...] — a direct array access with
no switch, no jump table, no function call. bcConstToValue function removed.
Ownership: resolved_values doesn't own string pointers (owned by VM intern table
after resolution). deinit frees only the slice itself. cloneStrippedProto shares
the slice (borrowed, like code/k/p).
Results: 28/31 matrix (parity maintained), 45/45 smoke pass, geomean 2.78×
(was 2.85×). field_access -15.6% (3.82× → 3.60×), global_arith -11.7%, lua_calls
-12.6%, branch_loop -11.6%. bcConstToValue completely eliminated from perf top.
Problem: rawGet and rawSet were not inlined into the dispatch loop — perf showed
real call instructions for OP_GETFIELD/OP_SETFIELD/OP_GETTABLE/OP_SETTABLE. rawSet
was 19.7% and rawGet was 7.6% of field_access cycles. The function call overhead
(register saving, prologue, epilogue) was significant for the hot path.
Fix: Inlined the fast path (existing key lookup/update) directly into the 4 hot opcode handlers, eliminating the function call:
- OP_GETFIELD: Direct
nodeLookupfor string keys — norawGetcall. - OP_SETFIELD:
gcTableWriteBarrier+nodeLookupfor existing key update (ornodeDeletefor nil). Falls back torawSetfor new key insertion (slow path). - OP_GETTABLE: Inline
nodeLookupfor Int (non-array) and String keys. Falls back torawGetfor other types (Num, Bool, etc.) that need float→int coercion. - OP_SETTABLE: Inline fast path for String keys only. Int keys and other types fall
back to
rawSet(which has array fast path + float→int coercion).
PUC approach: PUC Lua inlines luaH_get in luaV_execute through macros. The fast
path is direct hash lookup for existing keys; slow path (rehash, new key insertion) stays
in luaH_set/luaH_newkey.
Results: 28/31 matrix (parity maintained), 45/45 smoke pass, geomean 2.79×.
field_access -31.1% (3.82× → 2.65×), hash_access -7.4%, global_arith -12.6%.
rawGet/rawSet completely eliminated from perf top on field_access.
Problem: PUC Lua has LUA_TLIGHTUSERDATA (type code 2) — a plain C
pointer wrapped as a Lua value, not garbage-collected. luazig lacked this
fundamental value type entirely: light userdata was faked via tables with a
__light field. This blocks real lua_pushlightuserdata and the C API and
prevents proper parity with PUC testC commands like isudataval,
topointer.
Fix: Added .LightUserdata: *anyopaque as the 10th variant of the Value
union. Updated all exhaustive switch sites across vm.zig, ltable.zig,
api.zig, and c_api.zig:
Value.typeName()/builtinType— returns"userdata"(PUC returns"userdata"for both light and full userdata).valuesEqual— pointer equality (two light userdata are equal iff same pointer).valueMetatable/builtinDebugSetmetatable— returns sharedlight_userdata_metatable(PUC'smt[LUA_TLIGHTUSERDATA]).gcMarkValue/gcValueAge/gcWriteBarrier— light userdata is NOT GC-managed, falls through toelse =>(like.Int/.Bool).keyHash/keyMatches/setKey/getKey/rawHashinltable.zig— light userdata as a table key: hashes by pointer address, compares by identity.writeValue/valueToStringAlloc— formats as"userdata: 0x{x}".topointer(testC) — returns pointer address.api.zig:Type— addedlightuserdata(PUC type code 2).valueTypereturns.lightuserdata;isuserdatareturns true for both.c_api.zig:lua_type/lua_getglobal— maps.lightuserdata => 2(PUCLUA_TLIGHTUSERDATA).
PUC approach: PUC Lua's LUA_TLIGHTUSERDATA is a plain void* in a
TValue. The GC does not own or trace it. A single shared metatable
(mt[LUA_TLIGHTUSERDATA]) is used for all light userdata values. Light
userdata can be a table key — hashes by hashpointer, compares by identity.
Results: 28/31 matrix (parity maintained), 45/45 smoke pass, no regressions.
Problem: PUC Lua's CClosure (C closure) is a GC-managed object holding a
lua_CFunction pointer plus upvalues. luazig had no native representation for
C closures — pushcclosure testC command was faked via table-based upvalues.
This blocks real lua_pushcclosure/lua_iscfunction/lua_tocfunction C API
parity.
Fix: Added CClosure struct (src/lua/vm.zig:409) mirroring PUC's
CClosure layout: c_function: *const fn(*anyopaque) callconv(.C) c_int +
upvalues array + GC fields (gc_age/gc_index). Added .CClosure: *CClosure
as the 11th variant of the Value union. Full GC integration: CClosure reuses
the same gc_closures registry and GcAge infrastructure as Closure —
swept/marked identically. Updated all exhaustive switch sites across vm.zig,
ltable.zig, api.zig.
PUC approach: PUC's CClosure (lua-5.5.0/src/lobject.h:247-253) holds
lua_CFunction f + UpVal *upvals[1] (flexible array member). It is a
GCObject with CommonHeader, allocated via luaM and linked into allgc.
Results: 28/31 matrix (parity maintained), 45/45 smoke pass, no regressions.
CClosure call dispatch is stubbed (self.fail("CClosure call not yet implemented")) — actual C function invocation is future work.
Problem: PUC Lua's ltests.c defines 97 unique testC commands. luazig had
88/97 implemented — 10 were missing: abort/getmetatable/isudataval/
print/printstack/resetthread/throw/tointeger/touserdata/type.
These are needed for full testC parity.
Fix: Implemented all 10 missing commands in execTestcCommand
(src/lua/vm.zig:24494+):
abort— callsstd.process.exit(1)(PUCabort()).getmetatable— wrapsState.getmetatable.isudataval— checks both native.LightUserdata(P15.53) and table-based fakeisTestcLightUserdatafor backward compatibility.print/printstack— prints value/stack to stderr (PUCprintf).resetthread— resets thread state (TODO: full PUC semantics).throw— raises a Lua error (PUClua_error).tointeger— usesluaStrToNumfor string path +floatToIntCheckedfor safe i64 range check (PUClua_tointegerx).touserdata— usesmakeTestcPointerValuefor round-trip consistency.type— wrapsState.typeOf, returns PUC type name string.
PUC approach: All 10 commands are simple if/else if branches in PUC's
runC function (lua-5.5.0/testes/ltests.c:1583+).
Results: 28/31 matrix (parity maintained), 45/45 smoke pass, no regressions. All 97 PUC testC commands now implemented.
Problem: enableTestcModuleInternal passed empty upvalues (&.{}) to
runBytecode for the testC bootstrap chunk. The bootstrap source uses global
accesses (require, setmetatable) that compile to OP_GETTABUP on upvalue 0
(_ENV). With empty upvalues, gettabup caused an out-of-bounds access
(SIGSEGV) — all 6 testC lane suites crashed immediately.
Fix: Use createBytecodeChunkClosure + applyLoadEnv + runClosure
instead of direct runBytecode(proto, &.{}, ...), matching how
compileTextChunk + builtinDofile load chunks.
Results: testC lane goes from 0/6 (all SIGSEGV) to 2/6 pass (errors.lua,
strings.lua). Remaining 4 failures are assertion failures, not crashes:
api.lua:178— testCcallwith many returnscoroutine.lua— timeout (possible hang)locals.lua:685— assertion failurememerr.lua:133— assertion failure
Problem: builtinTestcTestC, builtinPcall, builtinXpcall error paths
used stale outs slice after callBuiltin/runClosure triggered bc_stack
realloc. Also opTforcall had LUA_MULTRET UB (nresults < 0 cast to usize).
Fix: Added refreshBuiltinOuts() (vm.zig:9802) — re-derives outs slice
from bc_stack after realloc. Called in all error paths. Fixed opTforcall
MULTRET by checking nresults < 0 before cast.
Problem: gcPropagateOne used th.bytecode_stack directly for varargs
scan, but for VM-active thread it's empty (moved to bc_stack).
Fix: Use stack variable with fallback, computed once before both regs
and varargs scans.
Problem: PUC Lua 5.5 has two modes for named varargs (...arg):
- PF_VAHID (default, hidden args, no table):
arg[n]/arg.ncompile toOP_GETVARGreading extra args directly from stack. 0 allocations. - PF_VATAB (table exists): created lazily only when the vararg escapes
(assigned, returned, passed to call, written to via
arg[k]=v). 3 allocations.
luazig always created the table (3 allocations) and compiled arg[n] as
GETTABLE. This caused memerr.lua:138 to fail (b.aloc == 0 expected for
optimized vararg, but 3 allocations were made).
Fix (PUC-faithful):
- bytecode.zig: Added
getvargopcode (R[A] := vararg_param[R[C]]). - codegen_bc.zig: Added
vararg_varandvararg_indexExpDesc variants. Whenargis looked up by name, returnvararg_var(virtual). Whenvararg_varis indexed for reading (arg[n]/arg.n), compile togetvarginstead ofgettable. When the vararg escapes (discharged to register, returned, passed to call, captured as upvalue, written to viaarg[k]=v), callneedVarargTable()to materialize a real table (PF_VATAB). Special case:_ENVas named vararg always materializes (environment table). - vm.zig: Implemented
getvarghandler — reads from extra args slice on stack (bc_stack[base - nextraargs .. base - 1]). Handles integer keys (1-based index), string"n"(returns count), and float keys (integer coercion, matching PUCtointegerns). - VARARGPREP: Only creates table if
vararg_table_regis set (PF_VATAB); otherwise just sets upnextraargs(PF_VAHID, already implemented).
PUC approach: needvatab() (lobject.h) marks a function as needing a real
vararg table. luaT_getvararg (ltm.c:292-311) reads from extra args directly.
luaK_vapar2local (lcode.c:808) converts virtual vararg to local when escaped.
Results:
memerr.lua:133(b.aloc == 3) — passes (vararg table materialized forpack).memerr.lua:138(b.aloc == 0) — passes (optimized vararg, no table).vararg.lua— passes (upvalue capture +_ENVspecial case fixed).- Matrix: 30/31 (was 28/31, +2: vararg.lua now passes, memerr.lua matrix pass).
- Smoke: 45/45, no regressions.
- testC lane: 3/6 pass (errors, strings, memerr lines 133+138). memerr.lua
still hangs after "file creation" testamem — pre-existing bug unrelated to
vararg changes (accumulated state after many
T.alloccount/T.totalmemcycles causes hang in latertestamemcalls).
Two bugs fixed:
Bug 1: testc_total_bytes never decremented on GC free (memerr.lua hang)
T.totalmem() returns testc_total_bytes, which was a monotonically growing
counter — only incremented by testcChargeMemory, never decremented when GC
freed memory. In PUC Lua, l_memcontrol.total (ltests.c:freeblock) is a
net counter: incremented on alloc, decremented on free. Without this,
testbytes in memerr.lua loops forever: M = T.totalmem() starts at the
ever-growing total, and no amount of M += 7 can catch up to a counter that
never decreases after collectgarbage().
Fix: Added gcNoteFree(bytes) helper (mirrors gcNoteAlloc) that
decrements both gc_count_kb and testc_total_bytes. Replaced all 10 inline
gc_count_kb = @max(0, ...) decrements in GC sweep paths (young + incremental:
tables, closures, threads, strings, cells) with gcNoteFree calls.
PUC reference: debug_realloc (ltests.c:196-265) tracks all allocations
through the custom allocator. luazig doesn't have a custom allocator, so
testcChargeMemory charges approximate sizes at specific call sites. Despite
drift between charge and free amounts, the counter correctly decreases when GC
frees memory, allowing testbytes to converge.
Bug 2: emitSetName didn't call ensureUpvalue (function-sugar upvalue bug)
function foo() ... end inside a closure didn't assign to upvalue foo — it
silently wrote to _ENV[foo] instead. emitSetName only checked
self.upvalues.get(name) (already-registered upvalues), but didn't try
ensureUpvalue to capture from the outer scope. Compare with genNameExpDesc
(reading a name) and genSet (general assignment), which both call
ensureUpvalue as a fallback.
Fix: Added ensureUpvalue call in emitSetName between the existing
upvalue check and the global fallback, mirroring genSet's logic.
Results:
memerr.lua— now passes completely (was hanging after "file creation").- testC lane: 4/6 pass (errors, memerr, strings + previously passing). Remaining: api.lua:815 (GC color tracking), coroutine.lua (timeout), locals.lua:1130 (close continuation).
- Matrix: 28/31 (no regression: attrib zig_fail, big both_fail, files both_fail).
- Smoke: 45/45, no regressions.
Problem: PUC Lua's GC uses per-object tri-color mark bits stored in
CommonHeader.marked (lgc.h:79-86). luazig had no per-object mark bits —
GC marking was done via a HashSet of visited pointers, which cannot support
T.gccolor() (testC command to inspect an object's GC color: white/gray/black)
or proper generational GC age tracking.
Fix: Added gc_marked: u8 field to all GC objects: Table, Closure,
Thread, LuaString, Cell. Implemented PUC's bit layout:
WHITE0BIT = 1<<3,WHITE1BIT = 1<<4,BLACKBIT = 1<<5FINALIZEDBIT = 1<<6,TESTBIT = 1<<7- White = has white bit matching
gc_current_white - Black =
BLACKBITset; Gray = neither
Added gc_current_white: u8 on Vm (toggled each collection cycle). Added
gcIsWhite, gcSetWhite, gcSetBlack, gcSetGray, gcFlipWhite helpers.
Implemented testC commands:
T.gccolor(obj)— returns "white"/"gray"/"black" stringT.gcstate()— returns current GC state stringT.querytab(tab)— returns array of {key, value} pairs (for GC introspection)warn(msg)— PUClua_writestringerrorequivalent
Results:
- testC: 3/9 pass (errors, memerr, strings).
- Matrix: 28/31, smoke 45/45 — no regressions.
Three bugs fixed:
Bug 1: Generational GC never advanced object ages after full collection
gcFullCollectionForUser (gen mode path) called gcMakeAllOld but didn't
clear gc_finalizer_tick_pending or set gc_step_debt_kb afterward. This
caused the generational collector to never properly transition objects to
old generation, leading to incorrect GC behavior in gengc.lua.
Fix: Clear gc_finalizer_tick_pending and set gc_step_debt_kb after
gcMakeAllOld in the gen path. Also set gc_busy = true during the
gcMakeAllOld transition to prevent re-entrancy.
Bug 2: registerFinalizable set gc_finalizer_tick_pending unconditionally
PUC Lua does NOT force a GC step when registering a finalizable object
(luaC_checkfinalizer doesn't trigger GC). Our code did, causing incorrect
GC timing in generational mode.
Fix: Remove the gc_finalizer_tick_pending = true from
registerFinalizable.
Bug 3: gcRememberValue/gcRememberCell didn't set objects GRAY
PUC's luaC_barrierback_ calls set2gray/linkobjgclist which turns the
owner gray and adds it to the grayagain list. Our implementation added to
grayagain but didn't set the gray mark bit, causing the object to be
re-scanned incorrectly.
Fix: Set gcSetGray when adding to grayagain in both
gcRememberValue and gcRememberCell.
Results:
- testC: 4/9 pass (errors, memerr, strings, gengc).
- Matrix: 28/31, smoke 45/45 — no regressions.
Problem: PUC Lua has TWO distinct write barriers:
- Forward barrier (
luaC_barrier_/luaC_objbarrier): marks the VALUE. Used forsetmetatable,lua_setupvalue,OP_SETUPVAL,OP_CLOSURE. - Backward barrier (
luaC_barrierback_/luaC_objbarrierback): turns the OWNER gray and adds to grayagain. Used for table writes (luaH_set,OP_SETTABLE,lua_rawset).
luazig had a single barrier that didn't distinguish these cases, causing
incorrect GC color tracking in gc.lua (objects marked wrong color).
Fix: Split into two barrier functions matching PUC semantics:
gcWriteBarrierTable— backward barrier: turn table gray, add to grayagaingcWriteBarrierCell— forward barrier: mark value if cell is blackgcStoreMetatable— forward barrier: mark metatable (OLD0 in gen mode)gcStoreClosureEnv— forward barrier: mark value (OLD0 in gen mode)
In generational mode, forward barriers set the value to OLD0 age (matching
PUC's genlink behavior).
Results:
- testC: 4/9 pass (gengc still passes). gc.lua progresses (fails at 569 instead of earlier — the remaining failure is the open-upvalue issue).
- Matrix: 28/31, smoke 45/45 — no regressions.
Two fixes:
Fix 1: gcQueueScanCell — open vs closed upvalue marking (PUC
reallymarkobject for LUA_VUPVAL)
PUC's reallymarkobject for LUA_VUPVAL keeps open upvalues GRAY (not
BLACK) to avoid barriers — their values are revisited by the thread or
remarkupvals. Closed upvalues are turned BLACK (fully visited).
Previously, gcQueueScanCell always set cells BLACK. Now:
- Open upvalues (
bc_stack_idx != null): set GRAY, add to grayagain - Closed upvalues (
bc_stack_idx == null): set BLACK
Also unified all cell marking sites to use gcQueueScanCell:
gcMarkMutableRoots, gcMarkValue (Closure case), gcMarkClosureFinalizerReach.
Note: Currently all cells have bc_stack_idx == null (closed) because
the bytecode VM doesn't implement truly open upvalues (cells capture values,
not stack references). This is a known architectural gap — fixing it requires
implementing PUC-style open upvalues (luaF_findupval returning a pointer to
the stack slot). The gc.lua:569 failure is caused by this gap.
Fix 2: gcFinalizeLessThan — LIFO creation order (PUC finalization order)
PUC finalization order: objects are prepended to finobj (LIFO), moved to
tobefnz preserving order, and finalized from the beginning. So the most
recently created finalizable object is finalized first.
Previously, gcFinalizeLessThan used pointer comparison as a tiebreaker,
which is non-deterministic across runs. Now uses gc_index (registration
order in gc_tables) descending — matching PUC's LIFO creation order.
Results:
- testC: 4/9 pass (errors, gengc, memerr, strings). gc.lua:569 (open upvalue gap), nextvar:41 (table rehash), coroutine (timeout), locals:1130, api:941.
- Matrix: 28/31, smoke 45/45 — no regressions.
Problem: PUC's luaC_barrier_ (forward barrier) has two branches:
keepinvariant(g)(propagate/atomic): mark the value (reallymarkobject)- sweep phase: make the owner white (
luaC_makewhite)
luazig's forward barriers (gcWriteBarrierCell, gcStoreClosureEnv,
gcStoreMetatable) only implemented the propagate/atomic branch — they
marked the value in all non-pause states, including sweep. In sweep phase,
this incorrectly marked newly assigned values instead of making the owner
white.
Fix: All three forward barrier functions now check gc_state:
propagate/atomic: mark the value (existing behavior)sweep: make the owner white viagcMakeWhite(new)pause: no-op (existing behavior)
Note: gc.lua:569 still fails because the root cause is the lack of open
upvalues. In PUC, open upvalues are kept GRAY (not BLACK), so the forward
barrier doesn't fire (isblack(p) is false). In luazig, all cells are closed
(copy-by-value), so they're marked BLACK during atomic phase, and the barrier
fires incorrectly. Fixing this requires implementing PUC-style open upvalues
(luaF_findupval returning a pointer to the stack slot).
Results:
- testC: 4/9 pass (no change). gc.lua:569 still fails (open upvalue gap).
- Matrix: 28/31, smoke 45/45 — no regressions.
Problem: PUC Lua's UpVal is a GC object that can be OPEN (pointing to a
stack slot via uv->v.p) or CLOSED (holding its own copy in uv->u.value).
Open upvalues are kept GRAY during GC marking (not BLACK), which prevents
the forward barrier from firing when the stack slot is written. This is
essential for gc.lua:569, where a new table assigned to a captured local
must remain white (unmarked) during the atomic phase.
luazig's Cell had bc_stack_idx field but it was always null — all
upvalues were effectively closed (copy-by-value). This caused the forward
barrier to fire incorrectly, marking newly assigned values gray.
Fix: Implemented PUC-faithful open upvalues:
-
Cell.bc_stack_thread: ?*Thread— identifies which thread's stack the open upvalue points to. Needed because a suspended coroutine's stack lives inth.bytecode_stack, notvm.bc_stack. -
Cell.resolveStack(vm)— returns the correct stack slice: if the owning thread is currently active (vm.active_runtime_thread == th), usesvm.bc_stack; otherwise usesth.bytecode_stack. -
Cell.get(vm)/Cell.set(vm, v)/Cell.close(vm)— now take*Vminstead of[]Value, usingresolveStackto find the correct stack for open upvalues. -
OP_CLOSURE — creates open upvalues with
bc_stack_idx = ctx.base + uv.idxandbc_stack_thread = self.activeBytecodeThread(). -
OP_MOVE slow path — for OPEN cells, the register write already updates the stack slot (which IS the cell's value), so
gcStoreCellValueis skipped. For CLOSED cells, sync + barrier as before. -
closeBytecodeUpvaluesFrom/ tail call close — usescell.close(self)to properly snapshot the stack value, then fires the write barrier. -
gcQueueScanCell— for open upvalues, returns early after setting GRAY (value is on the thread's stack, scanned separately). For closed upvalues, marks value as before. -
gcMarkClosureFinalizerReach— same open/closed distinction. -
bumpClosureNumericUpvalues— now takes*Vm, usescell.get()/cell.set()for proper open/closed handling. -
OP_CLOSURE — added
testc_obj_functions += 1(was missing, causingT.totalmem("function")to undercount).
Results:
- testC: 5/9 pass (errors, gc, gengc, memerr, strings). gc.lua now passes completely! Remaining: nextvar:41 (table rehash), coroutine (timeout), locals:1130, api:941.
- Matrix: 28/31, smoke 45/45 — no regressions.
Problem: Three issues blocked api.lua and gengc.lua:
gcFullCollectionForUsercheckedfinalizables.count() > 0for the second cycle, butgcFinalizeListremoves fromfinalizables, so the second cycle never ran. Finalized objects survived but were never freed.- In generational mode, old objects are BLACK. When
luaC_fullgcswitches to incremental and runs a full cycle, old BLACK objects are not visited during marking. Their metatables (if young/white) get swept, causing use-after-free crashes in gengc.lua. T.loadlib(L, 1|2, 0)didn't setenv._G = envwhen bit 1 was set, so_ENV = _Gin doremote scripts resolved to the main_G(with all stdlib) instead of the isolated env.
Fixes:
- Added
gc_finalizers_ran_countfield, set ingcFinalizeList. ChangedgcFullCollectionForUserto checkgc_finalizers_ran_count > 0instead offinalizables.count() > 0for both incremental and generational paths. Reset to 0 before each second cycle to prevent recursive third cycles. - Added
gcMakeAllWhite(): resets all GC objects to current white before the full incremental cycle in generational mode. This is PUC'smarkbeingoldequivalent — ensures the incremental mark/sweep correctly identifies unreachable objects (they stay old-white after the white flip and are swept). Called beforegcCycleFull()in the generational path ofgcFullCollectionForUser. - Fixed
T.loadlibto setenv._G = envwhen bit 1 is set (PUCloadlibloads_Ginto the new state's environment).
Results:
- testC: 6/9 pass (api, errors, gc, gengc, memerr, strings). api.lua now passes completely! Remaining: nextvar:41 (table rehash), coroutine (timeout), locals:1130.
- Matrix: 29/31 (coroutine.lua now passes in matrix!). smoke 45/45 — no regressions.
Problem: When a __close metamethod (running as a bytecode closure via
resumeTestcCloseReturnContinuation) called coroutine.yield, the bytecode
frame was lost. Two root causes:
-
Wrong thread for continuation check.
builtinTestcTestCcheckedself.current_thread.testc_close_return_values, but by the time the testC builtin is re-entered after a yield,self.current_threadmay have changed due to nested coroutine operations during the yield/unwind. The continuation was resumed on the wrong thread. -
Bytecode frame unwound on yield.
canParkDirectBytecodeYieldonly allowed parking whenboundary_depth == 0(top-level yield). But a__closemetamethod runs viacallMetamethod→runClosure→runBytecodeInternal, which pushes a new frame (boundary_depth != 0). Whencoroutine.yieldreturnederror.Yield,runBytecodeInternal'serrdeferunwound the frame (becausebytecode_inplace_suspendedwas never set), destroying the__closemetamethod's execution state.
Fixes:
- Moved the
testc_close_return_valuescheck frombuiltinTestcTestCtobuiltinCoroutineResume, whereth(the coroutine thread) is known fromargs[0]. This mirrors the existingtestc_pending_contspattern. - Extended
canParkDirectBytecodeYieldto also returntruewhentestc_close_metamethod_depth > 0, allowingcoroutine.yieldfrom within a__closemetamethod to park the bytecode frame in-place even atboundary_depth != 0. - Added
bytecode_resume_boundaryfield toThread: stores theboundary_depthat park time sorunBytecodeInternalresumes with the correct boundary (not 0), preventing it from processing frames belonging to outer callers. parkDirectBytecodeYieldnow takes and storesboundary_depth.
Results:
- testC: 7/9 pass (api, errors, gc, gengc, locals, memerr, strings). locals.lua now passes completely! Remaining: nextvar:41 (table rehash), coroutine (pre-existing testc_lane pipe timeout; passes when run directly).
- Matrix: 28/31 (coroutine.lua regression from P15.63 fixed!). smoke 45/45 — no regressions.
Problem: T.pushuserdata(n) created a Lua table with fields
{__testud, __ptr, __val, __light, __isnull, __size} masquerading as light
userdata. An entire detection apparatus — isTestcUserdata,
isTestcLightUserdata, isTestcNullPointer, makeTestcPointerValue,
debugLightUserdataForId + debug_upvalue_ids proxy tables — existed only to
handle these workaround tables. Meanwhile, Value.LightUserdata: *anyopaque
was already fully supported.
Fix (PUC-faithful): T.pushuserdata(n) now returns
.{ .LightUserdata = @ptrFromInt(n) } — exactly as PUC lua_pushlightuserdata
does (ltests.c:1267-1271). All detection helpers collapsed to simple tag
checks (v == .LightUserdata). makeTestcPointerValue is now a pure function
returning Value (no self, no DispatchError). debugLightUserdataForId
returns real LightUserdata directly; debug_upvalue_ids proxy tables and
their GC marking/deinit removed entirely.
Dead code removed (~200 lines):
- Table-ud
__valrank logic intestcFinalizeRankObj __gc_tracked/__sizeaccounting inbuiltinDebugSetmetatable__uservalsfallback inbuiltinDebugSetuservalue/builtinDebugGetuservalue__sizebranch in testCobjsizehandlerisTestcUserdatachecks inbuiltinType,valueTypeName,isUserdataLike,checkTabArg.Table__valfallback inbuiltinTestcUdataval
Results:
- testC matrix: 26/31 (no regression — same 5 pre-existing failures).
- Normal matrix: 28/31 (no regression).
- Smoke: 42/42 — no regressions.
Problem: T.checkpanic used hardcoded string-matching hacks (string.find
on script/panic-script content) to return pre-baked results for each of the 8
checkpanic test cases. This violated AGENTS.md (no match-by-name/content for
semantic branching).
Fix (PUC-faithful): Replaced with a real sub-VM implementation matching
PUC checkpanic (ltests.c:1406-1432):
- B1: Fixed
Vm.deinitgaps —long_string_cacheandtestc_warn_buffwere never freed (required for sub-VM create/destroy lifecycle). - B2:
builtinTestcCheckpaniccreates an independentVm.init(alloc), runs the test script unprotected (RuntimeError = the "panic"), optionally runs a panic script on the same testC stack, then returns the top-of-stack string (matching PUC'slua_tostring(L1, -1)). The sub-VM inherits the parent's memory accounting (testc_mem_limit/testc_total_bytes) and bytecode compiler — matching PUC's shared allocator semantics. - B3:
threadstatusnow readstestc_thread_statusinstead of hardcoded"ERRRUN". The status is classified from the error message:"not enough memory"→ ERRMEM (PUCstatcodes[ERRMEM] == MEMERRMSG), else ERRRUN. This correctly handles both case 2 (ERRRUN) and case 5 (mem-error where threadstatus must return "not enough memory").
Supporting fixes (pre-existing bugs surfaced by running real panic scripts):
parseTestcWords: added double-quote handling (PUCgetstring_auxsupports both'and"). Previously, double-quoted strings likepushstring "function f() f() end"were split at internal spaces.trimTestcQuoted: removed whitespace trimming (destroyed leading/trailing spaces in quoted content likepushstring ' alo'→ " alo"). SinceparseTestcWordsalready trims unquoted tokens and extracts quoted content verbatim, trimming was redundant and incorrect.- Script normalization + statement splitting: now track double quotes (not just
single) for
#-comment stripping and;/\nseparator detection. loadstring: changed from in-place replace to push (matching PUCluaL_loadbufferxwhich pushes onto the stack without consuming the source). On failure, pushes only the error message (1 item), not nil+err (2 items) — matching the C API behavior, not the Lualoadfunction behavior.
Results: All 8 checkpanic cases pass (api.lua:412–475, memerr.lua:28). api.lua and memerr.lua now fully pass.
- testC matrix: 26/31 (no regression).
- Normal matrix: 28/31 (no regression).
- Smoke: 45/45 — no regressions.
Цель: закрыть главный 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.
Завершённая фаза (план: docs/superpowers/plans/2026-07-29-c-extension-loading.md).
Цель достигнута: загружаемые C-расширения (lib1.so, lib2.so, PUC test libs) имеют полный
доступ к VM через C API: c_stack, package.loadlib через std.DynLib,
внешние строки. Архитектурно следует PUC Lua (lua_State == Vm, отдельный
push/pop стек для C-API поверх bc_stack), без libc-fallback и без name-based
special-casing. attrib.lua проходит, testC matrix 26/31 (code/cstack pre-existing),
normal 28/31, smoke 45/45, testc_lane 9/9.
-
A1 —
c_stackна Vm, перезапись C-ABI shim на*Vm. Добавленc_stack: ArrayListUnmanaged(Value)вVm; всеlua_*export-функции вc_api.zigработают напрямую с*Vm/c_stack.lua_Statealiased toVm. (commitfa1bcf0.) -
A2 — Недостающие C API функции. Добавлены ~15 функций в
c_api.zig: стек (lua_pushvalue/lua_insert/lua_remove/lua_rotate), таблицы (lua_createtable/lua_setfield/lua_getfield/lua_rawset/lua_rawget), строки (lua_pushlstring/lua_pushliteral/luaL_checklstring), регистрация (luaL_setfuncs/luaL_newlibчерезluaL_Reg), misc (luaL_checkversion/lua_pushfstring/luaL_ref/lua_pushcfunction). ПолеClosure.c_func(lua_CFunction) добавлено;Vm.c_ref_counter— счётчик дляluaL_ref;gcRegisterClosureсделанpub.setfield/getfieldидут через метаметод-уважающиеapiSetTable/apiGetTable(как PUCluaV_finishset);rawset/rawget— черезapiRawSet/apiRawGet.lua_rotateвыведен из трёх-reversal алгоритма PUC (исправлено направление относительно наивногоstd.mem.rotate(items, n)). Regression: matrix 28/31, smoke 45/45, новых регрессий нет. Review-fix (коммит0b53582→amend): NUL-terminate вcreateLuaString(PUCluaS_createlngstrobj), чтобыluaL_checklstringмог отдавать safe C-string;luaL_refразличаетLUA_REFNIL(-1)/LUA_NOREF(-2);cAbsIndexдедуплицирован сnormalizeIndex; NULL-func placeholder вluaL_setfuncs(PUC pushesfalse).Note:
zig build testpre-existing сломан (api.zig:548stalecompileChunk,codegen_bc.zigtest-блоки — toolchain drift; не относится к A2). c_api unit-тесты проверены diagnostic-раном. Отдельная cleanup-задача.
- B1 — Call dispatch для
Closure.c_func. C-замыкания (созданныеluaL_setfuncs/lua_pushcfunction,proto == null,c_func != null) теперь вызываются через C-функцию вместо bytecode. Диспетчеризация сделана центрально вrunClosure(аналог PUCluaD_precall— единственная точка проверкиLUA_VCCL):if (cl.c_func) |cf| return callCFunction(cf, args)перед доступом кcl.proto.?. Это покрывает все ~30 call-site'ов одним изменением — OP_CALL (no-proto fallthrough),builtinRequire,apiCall/lua_pcallk,callMetamethod, pcall/xpcall, table.sort/gsub и т.д. — вместо scatter-проверок в каждой точке. НовыйcallCFunctionмоститbc_stack↔c_stack: swap-in свежегоc_stackс аргументами в[0..nargs](положительные индексы C-API absolute-from-0, см.c_api.normalizeIndex), прямой вызовcf(self), сбор результатов выше аргументов, restore caller'sc_stack. Swap O(1) (trivially-copyableArrayListUnmanaged), корректно под вложенными C→C вызовами. Без setjmp/longjmp (B2) и без loadlib (C1). Regression: matrix 28/31 normal / 26/31 testc (zig_fail=0/zig_fail=2 — без новых регрессий), smoke 45/45. - B2 — setjmp/longjmp error boundary для
lua_error/lua_call(pure Zig, без C-враппера). В отличие от первоначального плана (src/c/cfunc_wrap.c), граница реализована целиком в Zig черезextern fn _setjmp/_longjmp(libc уже слинкован с A1).callCFunctionWithBoundaryставит_setjmplanding pad в собственном кадре:_longjmpизlua_errorвозвращается сюда, функция берёт нормальный путьreturn -1, иdefer-ы отрабатывают как при обычном возврате (ни один Zig-кадр выше не разматывается — longjmp приземляется ВНУТРИ boundary-функции). Это PUC-faithful форма:luaD_rawrunprotectedсам по себеsetjmp+call+return. Используется_setjmp/_longjmp(неsetjmp/longjmp) — эквивалент PUC__sigsetjmp(env, 0)без сохранения signal mask, без syscall на каждый вход. ПоляVm:c_error_jmp: ?*anyopaque(текущийL->errorjmp),c_error_value: ?Value(объект ошибки, перенесённый через_longjmp).lua_error(c_api.zig) захватываетc_stack-top вc_error_value, затем_longjmp(c_error_jmp, 1).callCFunctionна-1фолдитc_error_valueвerr_obj/err_has_obj/err(унификация с native runtime-error машиной pcall/traceback/coroutine) и возвращаетerror.RuntimeError;errdeferвосстанавливаетc_stack.lua_call(минимальный): успех черезapiCall, ошибка rethrow'ится через активную границу (_longjmp) при наличииc_error_jmp. Вложенные границы корректны (save/restorec_error_jmpчерезdefer, проверено отдельным mechanism-repro в Debug и ReleaseFast). Verification: изолированный repro longjmp-в-Zig (defer отрабатывает, локальныеprevпереживают longjmp, вложенность работает) — Debug + ReleaseFast; matrix 28/31 normal / 26/31 testc (zig_fail=0 / zig_fail=2 — оба pre-existing на HEAD, без новых регрессий); smoke 45/45. Юнит-тесты в c_api.zig добавлены, ноzig build testпока блокирован pre-existing поломкой test-блоков вcodegen_bc.zig(мех.Lexer.init(string)вместоSource— вне scope B2). Мёртвыйsrc/c/cfunc_wrap.c(никогда не компилировался, не в build.zig) удалён.
-
C1 —
package.loadlibчерезstd.DynLib+ реальный поиск C-библиотек вrequire. Заменена заглушкаpackage.loadlib(раньше возвращала(nil, "not supported", "absent")) на реальную реализацию черезstd.DynLib.open(Zig-обёртка надdlopen/dlsym).builtinPackageLoadlibподдерживает оба режима PUC: (1) probe (funcname == "*") — open + close + возврат no-op closure (llAccessible, аналог PUCll_accessible); (2) normal — open + lookupluaopen_*+ wrap вClosureдля интеграции с существующим C function dispatch (B1callCFunction). DynLib handle намеренно НЕ закрывается (leak на normal path) — PUC тоже не вызываетdlclose, т.к. C-расширения могут кешировать указатели на свои статические данные; библиотека остаётся mapped до конца процесса (Zig не имеет RAII деструкторов, поэтому drop локальнойDynLibне закрывает OS handle).builtinRequire(searcher_C path, PUCll_require): при неудаче Lua-path ищет.soнаpackage.cpathчерезsearchpath, строит символluaopen_<modname>(dots→underscores, PUCfindsym), вызываетloadlib(filepath, symbol), затемrunClosureс(modname, filepath), регистрирует результат вloaded[modname]. Error categories"open"/"init"соответствуют PUC. Regression: matrix 28/31 normal / 26/31 testc (zig_fail=0 / zig_fail=2 — без новых регрессий; attrib.lua в testc перешёл из zig_fail в both_fail — улучшение), smoke 45/45. Реальная загрузка .so требует полного C API (часть .so скомпилированных test-харнесом падает наluaL_checkversion_— задача E1). -
D1 — External strings для строк, принадлежащих загруженному .so. Реализованы PUC 5.5
lua_pushexternalstring/lua_getallocfи инфраструктура external-строк на уровнеLuaString+ GC.LuaStringполучил поляis_external,external_ptr,falloc,falloc_ud;bytes()разветвляется для external-контента;destroyLuaStringвызывает dealloc-callback перед освобождением хидера (PUC lgc.c:874-875). ДобавленVm.createExternalLuaString— PUC-faithful: всегда создаёт long string (LUA_VLNGSTR/LSTRMEM), без ветвления по длине (PUCluaS_newextlstrне зависит от длины).lua_getallocfвозвращает C-callable wrapper вокругvm.alloc(cApiAllocWrapper),ud = *Vm.LUA_REGISTRYINDEX = -1001000добавлен;luaL_refобрабатывает псевдо-индекс реестра. Regression: matrix 28/31 normal / 26/31 testc / smoke 45/45 — без новых регрессий. -
E1 — Интеграционное тестирование: загрузка реальных PUC test libs, attrib.lua проходит. Созданы минимальные C-заголовки
src/lua/lua.hиsrc/lua/lauxlib.h, точно повторяющие PUC Lua 5.5 (макросыlua_call→lua_callk,lua_pushcfunction→lua_pushcclosure,lua_tointeger→lua_tointegerx,lua_insert→lua_rotate,luaL_checkversion→luaL_checkversion_,luaL_newlib→lua_createtable+luaL_setfuncs). Тестовые библиотеки (lib1.so,lib2.so,lib2-v2.so,lib11.so,lib21.so) скомпилированы черезzig cc -shared -fPIC -I src/lua.Решённые проблемы:
-
Символы C API не экспортировались в динамическую таблицу.
export fnв модуле удалялись линкером как dead code, т.к. из root-ZCU на них нет ссылок. Решение:comptime-блок вroot.zigберёт адрес каждогоpub export fn— это делает их живыми root'ами для DCE. Дополнительноrdynamic=trueвbuild.zig(PUC-Wl,-E). Всеexport fnвc_api.zigпомеченыpubдля доступа изroot.zig. -
CLIBS-кеш и RTLD_GLOBAL (PUC
lookforfunc/lsys_load). Probe-режим ("*") теперь открывает черезdlopen(RTLD_NOW | RTLD_GLOBAL)и кеширует handle вVm.c_libs(PUCCLIBS-таблица). Последующиеloadlibпереиспользуют кешированный handle. Это необходимо для inter-library dependencies:lib11.soвызываетlib1_exportизlib1.so— работает только еслиlib1.soбыла загружена сRTLD_GLOBAL. -
lua_pushfstringс реальным форматированием. Реализован парсер C-variadic (@cVaStart/@cVaArg/@cVaEnd) для PUC-множества спецификаторов:%d/%i/%u/%f/%g/%s/%c/%p/%x/%X/%o/%U/%%. До этого возвращал сырую format-строку. -
PUC
loadfuncconvention (LUA_IGMARK =-).require"lib2-v2"теперь пробуетluaopen_lib2(префикс до-), затемluaopen_v2(суффикс). Аналогично для C-root searcher:require"lib1.sub"→ ищетlib1.so, вызываетluaopen_lib1_sub. -
searcher_Croot(PUC loadlib.c:582). Добавлен C-root searcher вbuiltinRequire: при наличии точки в имени модуля ищет root-package на cpath, затем вызываетloadfuncс полным именем (lib1.sub→luaopen_lib1_sub). -
callCFunctionresult collection. Результаты читаются изc_stack[total-nret..](top-N по PUCluaD_poscall), а не изc_stack[args.len..]— C-функция может модифицировать стек черезlua_settop/lua_pushvalue. -
Добавлены
lua_callk,lua_pushcclosure,luaL_checkversion_,lua_copy— underlying-функции, в которые PUC-макросы раскрываются после препроцессинга. -
T.hash— добавлен в testC-модуль для проверки hash-parity внешних строк.
Результат:
attrib.luaпроходит (включая external strings, require, loadlib, C submodules). testc matrix: 26/31 pass parity (code.lua/cstack.lua — pre-existing zig_fail в testc mode, big.lua/files.lua — both_fail). Normal matrix: 28/31, smoke 45/45, testc_lane 9/9 — без новых регрессий. -
-
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
-
Eliminate ctx.fr pointer from dispatch hot path (commit
de34fc0): Replacedctx.fr: *CallFrame(pointer intoexec_framesheap array) with value fieldspc,is_tailcall,resumed_direct_yield,has_open_upvaluesonBytecodeDispatchCtx. This mirrors PUC Lua's localconst Instruction *pcpattern: the heap CallFrame is only synced at call/return boundaries (syncDispatchCtxdefer), not dereferenced per-instruction. Eliminates 3 heap dereferences per instruction (thectx.fr.pcpointer chase).- Added
Vm.dispatch_pc: usizefield — written per-instruction in the inner dispatch loop sofail()andsyncTopFrameForGccan read the current pc withoutctxaccess.fail()syncsfr.pc = dispatch_pcbefore reading lineinfo.syncTopFrameForGcsyncsdispatch_pcto frame (was a no-op). - OP_CLOSE: increment
ctx.pcin handler (not incontinueBytecodeClose) to avoid defer clobbering frame pc. - OP_CLOSURE: set
ctx.has_open_upvalues = true(was only on frame) so OP_MOVE's fast path correctly takes the slow path that syncs throughboxed[]cells. callBuiltin(opCall/opTailcall/opTforcall): sync pc/reg_top/nvarstack to frame before call (GC readslive_reg_top[pc]from frame).- Fixed IDIV
minint // -1overflow: returnminInt(i64)(was 0). Geomean: 2.88× (parity preserved: 28/31 matrix, 44/45 smoke). No perf change — eliminated dangling-pointer hazard and adopted PUC's local-pc pattern, but the per-instructiondispatch_pcstore offsets the saved heap dereferences.
- Added
Детальная история оптимизаций, промежуточных замеров и закрытых подпунктов сохранена в Git (git log).
Цель: заменить 5 раздельных per-type GC списков (gc_tables, gc_closures,
gc_threads, gc_cells, gc_strings) на единый GcObject tagged union
(как в PUC allgc), и добавить настоящий full Userdata тип (вместо
текущего *anyopaque light userdata hack). План:
docs/superpowers/plans/2026-07-27-gcobject-unified-and-userdata.md.
Чекбоксы по задачам плана:
- A1: Define
GcObjecttagged union and accessor functions (gcPtr,gcObjectBytes,gcCanFinalize). Pure addition — union defined but not wired into existing GC code yet (dual registration comes in A2). - A2: Dual registration — keep per-type lists AND
GcObjectregistry in sync. Unifiedgc_objects/gc_young_objectslists added to Vm;gcRegisterObject/gcUnregisterObjectwired into all 5 per-type register/unregister functions. Usesgc_object_indicesside-table for O(1) lookup during migration (per-type lists still owngc_index); side-table removed in A5 when per-type lists go away. - A3: Migrate mark/sweep/propagate to use
GcObject+gcPtr.gc_graychanged fromArrayList(Value)toArrayList(GcObject);gcQueueScanObjectis the new type-generic entry point (strings → black, others → gray + queue);gcMarkValue/gcMarkMinorValue/gcQueueScanValuedelegate throughGcObject.fromValue;gcPropagateOneswitches on GcObject variants (.table/.closure/.thread/.string/.cell); closed cells follow PUCreallymarkobject(lgc.c:347-354): set black directly + mark content inline, never entering the gray list (PUCpropagatemarkhas noLUA_VUPVALcase).gc_mark_epochbump preserved (only on white→marked transition) to keep ephemeron fixpoint convergence correct.gc.luatest now passes (was timing out due to epoch always-bump bug).gc_grayagainstillArrayList(Value)— A4 will migrate it. - A4: Migrate generational lists (
gc_old1,gc_grayagain) toGcObject. Eliminated parallelgc_old1_cells/gc_grayagain_cellslists — Cell is now folded into the unified lists via.{ .cell = cell }.gcRememberObjectis the new GcObject-typed entry point for backward barriers (wrapsgcRememberValue/gcRememberCell).gcCorrectOld1/gcCorrectGrayAgaincollapsed to single GcObject loops viagcPtr.gcDrainGrayagainmerged into a single loop (cells mark value inline + go black; others re-queue intogc_gray).gcMinorCollectiongrayagain re-traversal routes cells throughgcQueueScanCell(preserving PUC open/closed distinction). - A5: Unify sweep — eliminate
GcSweepKind. Replaced the per-type incremental sweep (7-phaseGcSweepKindenum walkinggc_tables/gc_closures/gc_threads/gc_strings/gc_cellsin sequence) with a single-pass sweep overgc_objectsin allocation order (PUC-faithful:sweeplistwalksallgc). AddedgcFreeObject(centralized type-specific teardown: per-type unregister + memory free +gcNoteFree) andgcHasFinalizer(centralized finalizer detection viafinalizablesset). RemovedGcSweepKindenum,gc_sweep_kind/gc_sweep_cursorfields, and per-type snapshot lengths (gc_tables_snapshot_lenetc.); replaced withgc_sweep_objects_cursorwalking againstgc_objects_snapshot_len. Per-type lists still maintained (removed in A7) —gcFreeObjectcalls per-typegcUnregister*to keep them consistent. - A6: Unify generational sweep and make-all-white/old. Replaced the five
per-type young sweep functions (
gcSweepYoungTables/Closures/Threads/Strings/Cells) with a singlegcSweepYoungObjectsovergc_young_objects. ReplacedgcPromoteYoungValue/gcPromoteYoungCellwith unifiedgcPromoteYoungObject(preservesgc_gen_added_old_kbtracking for minor→major threshold andgc_gen_threadsappend for promoted threads — PUCtwups-equivalent root scanning).gcMakeAllWhite/gcMakeAllOld/gcClearFinalizedBitnow iterategc_objectsviagcPtrinstead of per-type lists.gcClearGenerationalListsclearsgc_young_objects/gc_old1/gc_grayagain/gc_gen_threads.gcMinorCollectionuses the unified snapshot and mark-reset.gcSweepYoungGenerationcalls the single unified sweep. Dropped thefinalizables.containscheck from young table sweep (consistent with A5'sgcSweepOne, which relies solely onFINALIZEDBIT). Per-type young lists and snapshot fields still exist (removed in A7) but are no longer swept. - A7: Remove per-type lists — single unified
GcObjectregistry. Removed all 5 per-type list fields (gc_tables/gc_closures/gc_threads/gc_cells/gc_strings) and their young counterparts (gc_young_tablesetc.) and snapshot length fields. Removed thegc_object_indicesside-table (from A2) —gc_indexis now the sole index intogc_objects, written directly bygcRegisterObjectand read bygcUnregisterObjectfor O(1)swapRemove. Removed theptrKeyhelper. SimplifiedgcRegister*T/gcUnregister*Tto thin wrappers delegating to the generic functions. SimplifiedgcFreeObjectto only free memory (no unregister); sweep functions (gcSweepOne/gcSweepYoungObjects) now callgcUnregisterObjectbeforegcFreeObject.drainGcRegistriesiteratesgc_objectsdirectly.gcDeadenUnmarkedStringKeysiteratesgc_objectsfiltering for tables. Replaced ~31if (v == .Table or v == .Closure or …)chains ingcPropagateOnewithGcObject.fromValue(v) != null. GC refactor Part A is complete. - B1: Define
Userdatastruct withgc_marked/gc_age/gc_index+ metatable. - B2: Add
Userdatavariant toValueandGcObject. - B2.1: Implement
allocUserdata(PUCluaS_newudataanalogue),cmpEqtype-compatibility fix (PUCluaV_equalobj:__eqonly when both operands are the same type — fixesevents.lua:347root cause), GC write barriers for Userdata metatable/uservalues (gcForwardBarrierValue), anddebug.setuservalue/getuservaluereal-Userdata paths. Also added.UserdatatogcValueAge/gcSetValueAge/gcWriteBarrierso generational and incremental barriers fire correctly for Userdata owners. - A3 regression fix:
gcQueueScanCellfor closed upvalues had an early return (if (!gcIsWhite) return) before markingcell.value, causing the main chunk's_ENVcell (not GC-registered) to be skipped during marking → use-after-free whencollectgarbage("collect")swept the_ENVtable. Restored A2/PUC behavior:cell.valueis marked unconditionally for closed cells; only the cell's own color transition is gated onisWhite. Normal matrix restored to 28/31. - B3: Migrate
T.newuserdatafrom table emulation to real Userdata. Addtestc_newuserdatabuiltin, update testC bootstrap to use realallocUserdata, updateT.udataval/T.objsize/isTestcUserdata. Target: events.lua passes in testC matrix (26/31+). Result: events.lua, gc.lua, gengc.lua, nextvar.lua, api.lua all pass in testC matrix. testC matrix: 26/31 (5 fails: attrib, big, code, cstack, files — all pre-existing). Normal matrix: 28/31 (no regressions). Smoke: 45/45. Key fixes: -gcFinalizeLessThanswitched fromgc_index(corrupted byswapRemove) togc_seq(monotonic creation counter, set once, never changed) for correct LIFO finalization order. -gcMarkClosureFinalizerReach/gcMarkTableFinalizerReach/gcMarkThreadFinalizerReachnow mark the object itself black (PUCmarkfinalizercallsmarkobject), not just set FINALIZEDBIT. Without this, closures/tables/threads stayed white and were swept (freed) — causing use-after-free when their children were later traversed by another finalizable object. - testCsetmetatablecommand now handles.Userdata(setsu.metatable+ write barrier). - testCtestudatacommand now handles.Userdata(checksu.metatableagainst expected metatable). -gcWriteBarrierUserdatagenerational mode check fires BEFOREgc_state == .pauseearly return — in gen mode, paused objects are old (black), mutations must be tracked for next minor cycle.- B4: Full regression testing and cleanup. Run testC matrix, normal matrix, smoke, testc_lane. Update README. Result: testC matrix: 26/31 (5 fails: attrib, big, code, cstack, files — all pre-existing). Normal matrix: 28/31 (3 fails: attrib, big, files — all pre-existing). Smoke: 45/45. testc_lane: 9/9 ok.
- Implement PUC
fasttm(ltm.h:63-68,ltm.c:60-68) for cached metamethod lookup. Bit set inTable.flags= "metamethod absent" → skip hash lookup entirely (single AND instruction). Cache-on-miss: whenfasttmdoes the hash lookup and finds nil, sets the bit so future calls skip the lookup.TmsEventenum (vm.zig:1558): PUCTMSordering. Events<= .eq(index, newindex, gc, mode, len, eq) are cached inflags; events above (arithmetic, call, iter, close, etc.) always do hash lookup.tm_namesfield on Vm: pre-interned*LuaStringfor all 30 metamethod names, indexed byTmsEvent. Populated atVm.init(mirrors PUCluaT_init). Used byfasttmfor pointer-identity key comparison — avoidsinternStrAssumehashmap lookup on every call.fasttm(mt, event)(vm.zig:24949): checkflagsbit → if set, return null. OtherwiserawGetwith pre-interned name → if nil, set bit (cache-on-miss) → return null/value.metamethodValuenow routes throughfasttmfor cached events andmetamethodValueByEventfor non-cached. All__eq,__len,__gc,__mode,__index,__newindexlookups now usefasttm.- Dead-node revival fix: when
rawSet(or OP_SETFIELD inline fast path) updates a nil-valued node to non-nil, invalidatesflags(mirrors PUCluaV_finishset→invalidateTMcacheatlvm.c:347). This is required for events.lua:317-324 (set__eq=nilthen__eq=function— the nil→non-nil transition must clear the cached "absent" bit). - Result: Normal matrix: 28/31 (no regression). testC matrix: 26/31 (no regression). Smoke: 42/42. testc_lane: 9/9.