- add VpiStub: fixed-layout blob written next to every .vpi (dual write at all
writers, Ensure() backfills a missing or stale companion on cache reuse)
- serve arity/signature queries for top-level functions from the blob, with a
silent fallback to the text scanner (methods, varargs, duplicates, corrupt file)
- prefer the injected declare over the text arity in the cross-module check
- CDefine/type text indexes and in-place SSA renumbering and bulk appends
- WIP: char* conversion in CallGenBase and CallGenLeafKit
--spend-file <path> instruments every compiled function with
vpf_enter(id, name) / vpf_leave(id); main additionally calls
vpf_install(path, level). includes/vprofile.vp keeps the tables (no
allocation on the hot path), times with QPC and writes the report from
atexit, so normal return and exit() both produce it.
--vpf 0 off | 1 call counts | 2 +per-function time (default) |
3 +caller/callee edges
*A report path is resolved against the output exe directory, so
../prof.txt and absolute paths behave as documented regardless of the
process cwd.
*Instrumented include objects use their own cache suffix
(.binary.vpf<N>, stacked with the asan/traceback suffixes) and that
cache is cleared once when profiling is enabled.
*StubMergerBuild force-adds the vprofile runtime to the reachable set;
FuncGenBase emits a declare on demand because the instrumented module
usually does not import vprofile.
*--repl is rejected: the fragment DLL is loaded at runtime and does not
link the runtime.
--build-self no longer keeps a whitelist: SelfBuild.CollectForwardedArgs
drops only --build-self / --project / --build-dir and forwards every
other argument verbatim, so a self-build can enable the same modes as a
normal build.
Raw-IR modules (`__llvmir__` / `__mlir__`) were the last common case that still needed
child processes: llvm-link merged the `.raw.ll` sidecar into the translated module, then
`opt -passes=always-inline,globaldce` inlined the raw bodies. Both now run inside the DLL:
- new `ircore_mlir_to_obj_raw(mlir, len, raw_ir, raw_ir_len, ...)`: the sidecar is parsed
into the same LLVM context, merged with LLVMLinkModules2, then run through
always-inline,globaldce via LLVMRunPasses (IRCORE_FLAG_INLINE covers the `.inline`
marker case, which has no sidecar)
- the worker reads the sidecar itself with stdlib buffers (MemBuddy is not thread-safe),
so `.ll.pre`, `.ll.link` and `.ll.inline` intermediates are gone as well
- LLVMLinkModules2 is exported by libLLVM-22.dll but no longer declared in the llvm-c
headers we build against, so it is declared by hand (stable C ABI)
- PhaseB falls back to the legacy flow only for --phase2command now
Verified: RawIRTest runs through ircore with no `.mlir.opt`/`.ll.pre`/`.ll.link`/
`.ll.inline` leftovers and prints the same results as the legacy build; the FFI suite
gained a raw-merge check (the sidecar symbol is undefined without it, linked and inlined
away with it); TestFBProject (also uses __llvmir__) and 12 representative projects pass.
--traceback only needed one thing from llc (the per-module part): -frame-pointer=all. The
codegen flags are now a parameter of the FFI instead of a reason to fall back:
- ircore_mlir_to_obj_opts / ircore_llvmir_to_obj_opts take IRCORE_FLAG_* bits:
ASan instrumentation and frame pointers
- frame pointers are applied by adding the "frame-pointer"="all" function attribute to
every definition (what clang emits for -fno-omit-frame-pointer; the backend honours it
and it overrides the target default that llc's flag would have set), after ASan ran so
its generated helpers are covered too
- PhaseB calls the opts entry point with flags = asan|traceback, so neither mode starts a
child process anymore; the legacy command flow is unchanged for --phaseb-legacy,
--phase2command and raw-IR modules
Verified: a --traceback build has no .mlir.opt leftovers, the object carries the same rbp
prologues as `llc -frame-pointer=all`, the <exe>.stackmap is produced (24616 bytes, same
as the legacy build) and the executable runs; --asan still reports the same heap overflow;
12 representative Test projects pass with the deployed compiler.
--asan used to force the legacy PhaseB because the instrumentation step was a separate
`opt -passes=forceattrs,asan -force-attribute=sanitize_address -asan-globals=false` child
process. ircore.dll now exposes ircore_mlir_to_obj_asan / ircore_llvmir_to_obj_asan, which
run the same passes through LLVM's C pass builder (LLVMRunPasses) between MLIR lowering and
codegen, so ASan builds also avoid the three child processes.
- the two cl::opt values are parsed once (std::call_once) before the first pass run
- PhaseB loads the asan entry point together with the others and calls it when _asan_mode
is set; the legacy `opt` path stays for --phaseb-legacy and is unchanged
- --traceback (llc -frame-pointer=all) and raw-IR modules (llvm-link/opt) still take the
legacy flow
Verified on a real VKernel module: the ircore-built object and the `opt`-instrumented one
reference the same ASan runtime symbols (69 __asan_report / 4 asan.module_ctor), and a
deliberate heap overflow is reported identically by both builds.
PhaseB spawned three child processes per module (mlir-opt, mlir-translate, llc), and the
process startup dominated the cost: a 0.3 KB module took ~170 ms, of which ~150 ms was
just starting the tools; the 59-module VKernel set cost 18.4 s CPU (3.3 s wall on 16
workers).
ircore.dll (IRCore) does MLIR parse + verify + LLVM IR translation + codegen in a single
call. PhaseB now uses it from the worker threads:
- loaded at runtime with LoadLibraryA/GetProcAddress, not through the import table, so the
compiler still starts when the DLL is missing; one retry points the loader at clang64's
bin directory because ircore.dll needs that exact LLVM build
- falls back to the legacy flow automatically (with a warning) when the DLL cannot load
- `--phaseb-legacy` forces the legacy backend; the old command path is kept intact
(compile_ll_to_obj / init_llc_task / _llc_task_worker / run_llc_parallel)
- ASan instrumentation, raw-IR merging (llvm-link/opt), --traceback frame pointers and
--phase2command keep using the external tools
- new failure stage LLC_STAGE_IRCORE so a failure is attributed to the FFI call
Measured: 59 VKernel modules 18.4 s -> 4.8 s CPU, 3.3 s -> 1.6 s wall; AstTest
--rebuild-includes 13.6 s -> 11.3 s; BuddyTest 4.8 s -> 2.9 s; ViperC self-host 125 s ->
119 s (PhaseA dominates it); 41 Test projects pass, plus the 5 pre-existing failures.
Module-level functions and class methods each carried their own copy of "resolve the
return type", "create the parameter allocas" and "emit the implicit epilogue". The copies
drifted: an unannotated method was still typed i32 by the class-method body path while its
declaration said void, so the epilogue emitted `llvm.return 0 : i32` inside a void function
and the MLIR verifier rejected it ("'llvm.return' op expected no operands", Scheduler._yield).
- FuncGenBase.resolve_func_ret_ty: the single return-type policy (annotation -> storage
marker -> __init__/__new__ hooks -> unannotated = procedure void -> fail-fast). Used by
the forward-declare pass, the body pass, class methods and generic specializations
- FuncGenBase.param_llvm_ty: the single parameter mapping (CVoid skipped, bare class ->
Ptr(struct), ArrayT -> element pointer), used by every signature and alloca site
- FuncGenBase.emit_func_epilogue: the single implicit-return implementation (procedure ->
ret void, unreachable merge, fail-fast for an annotated fall-through), shared by module
functions, methods and nested functions
- FuncGenBase.translate_func_body: the shared body pipeline (scope entry, self/parameter
storage, *args va_list, try/except out-params, pre-scan, capture preparation, slife,
statements, epilogue, state restore). ClassGenVTable._translate_method now only handles
method naming, vtable/overload registration and add_param
- the same policy reaches cross-module and generic declares: unannotated vpi functions and
methods declare void, generic specialization declares use the shared resolver, and the
list.append call sites emit a void call
- method parameters now carry the const / volatile / pointee-volatile / pure markers that
the module-level path always had
- FuncGenMixin: the two dry-run helpers that return ints are annotated, as the language
now requires (an unannotated def is a procedure)
Verified: 41 Test projects pass (MethodProcTest, CrossModuleSigTest, RawPtrParamTest,
AstTest, ListTest, OopInheritTest, ...), ViperC self-hosts with the new compiler, and the
6 tests that still fail reproduce identically with the previous compiler.
Unannotated functions were typed by scanning the body, which inferred i32, so falling off
the end emitted `llvm.return 0 : i32` inside a function declared void. The MLIR verifier
rejects that ("expected no operands"), which is what broke Scheduler._yield.
- an unannotated `def` is void in both the forward declare and the body, so the implicit
epilogue emits `ret void`
- translate_return treats a procedure (no annotation, `-> None`, or a void-typed function) as
void: the expression is evaluated for its side effects and `ret void` is emitted. Bare
`return`, `return f()` where f is void, and `return <None value>` are legal and never carry
a value
- body_has_value_return accepts `return f()` and `return <None define>` in a procedure
- `define X = None` is a named None: registered as the string sentinel None, materialized as
a None value at use sites, and exported through the vpi
The cross-module return-type resolution derived only {sha} and the last segment from {sha}.Class.method, then queried the vpi cache and the dependency text.ll with no class name. Both key methods by class, so a class-level function without a return annotation (Scheduler._yield) missed both lookups and hit the fail-fast 'Cannot obtain the cross-module function return type precisely' path. Derive the middle segment and pass it as the class when it names a registered class; module functions ({sha}.submod.func) keep the bare-name lookup.
Injecting a dependency's module function deduped through a suffix match, so {other_sha}.init already in the module suppressed the bare @init declare of this module's export. Calls to tty.init(...) then suffix-matched the other module's init (1 parameter) and failed the arity check; the call-site module check also accepted a mangled symbol from another module whenever this module exported any function with that name.
- dedup a bare ExportT/State symbol by exact name only (VpiReader._inject_module_declare)
- accept a foreign-module binding only for bare symbols (the export/state form) or a re-export owner (CallGenMixin)
LookupSignDefaultsByName subtracted one receiver slot from every class-level def, but the vpi keeps the declared parameter list: a @staticmethod's first parameter is a real argument. Scheduler.exit_process(pid: int) therefore reported 0 parameters and the call-site arity check rejected exit_process(p.pid). Subtract the receiver only when the first parameter is actually named self/cls.
A suffix lookup for func() also matches {sha}.Class.func symbols. The no-self exception added for sched.Scheduler._yield() was too broad: it kept such a method hit for a module-qualified call like proc.current(), returning Process* where ProcessManager.current() -> int is meant (proc.current() is a module-level function). Keep the namespaced-function exception only when the receiver path itself names the class (mod.Class.func()); bare and module-qualified calls reject method-shaped suffix hits and resolve the module symbol through the vpi.
Two follow-ups to the cross-module signature check:
- A method without a return annotation is void, like a module-level `def`. The vpi
return-annotation cache recorded it as a hardcoded i32, so callers invented an i32 return
for a void method. It now records `void` for non-extern declarations (methods included).
- A class-level function declared without `self` (`Class.func()`) is a namespaced plain
function, not a method bound to a receiver. The module-qualified call path rejected such
symbols as method bindings, which then fell through to the external-C whitelist and failed
on a void return (`Scheduler._yield`).
- A pointer argument passed to an integer-typed parameter is converted at the call boundary
instead of failing the implicit pointer-to-integer rule. A `rawptr` value whose callee
parameter degraded to uint64 (stale/mis-typed stub) now reaches the callee with its address
instead of aborting with a CastError.
mlirlite.build_call composes the llvm.call function type from the argument values and the
caller-supplied return type; it never consults the callee. Declares are synthesized the same
way (_ensure_method_declare appends missing params from the caller's own argument values and
never compares return types), so nothing verified that a call site agreed with the definition.
Two modules could therefore emit incompatible calls for the same symbol, which only the MLIR
verifier rejected later:
llvm.func @MapHHDM(i64, i64, i64) (definition, void)
llvm.call @MapHHDM(i64, i64, i32) -> i32 (caller)
- after the declare/definition exists, conform every fixed argument to its parameter type and
emit the call with the symbol's return type (_conform_call_args / _call_symbol_ret_ty)
- fail fast when a cross-module call site resolves a return type the symbol disagrees with,
instead of emitting both and letting mlir-opt report an operand type mismatch
- record a module-level `def` without a return annotation as `void` in the vpi return-annotation
cache (previously a hardcoded i32, so callers invented an i32 return for void exports);
extern/state C declarations and methods keep the legacy default
`define GW_F = double(GW)` was registered as an integer constant: the DefineT
registration only took the float path when the initializer contained a float
literal, and the cast was unwrapped before the value was read. A float cast
produces a float even without a literal, so the define became an i16 640 and
define GW_F = double(GW)
define GH_F = double(GH)
aspect: double = GW_F / GH_F
folded with integer division to 1 instead of 16/9, which broke Gargantua's
projection (and any aspect/scale ratio derived the same way).
- decide the constant kind from the original initializer (a cast to a float
type, a float literal, or a reference to a float define) and evaluate it as
a double, keeping the f32/f64 choice from the annotation or `float(...)`
- resolve DefineT names inside float expressions, so `double(GW)` and
`double(A) / double(B)` evaluate at compile time
- .vpi generation writes such defines as float literals (`= 640.0`) instead of
running the integer evaluator, which also fixes a SIGFPE when that evaluator
hit `0 / 0` for a float expression; integer division/modulo now guard a zero
right operand instead of trapping the compiler
Include .obj files are cached under <includes>.binary_mlir/<sha>.obj and keyed by the
module's own source sha1 only, while the object body embeds the sha1-prefixed symbols of
every module it calls. After a dependency (or the compiler) changed, the cached object was
still reused, so the link either failed with an undefined symbol for a module sha1 that no
longer existed, or ran code compiled against the previous module graph. Restoring a source
file to a previously built revision therefore surfaced old codegen, which looks like a
compiler regression.
Store a stamp in the cache directory holding the running compiler's build id (exe size and
last write time) plus the sorted sha1 set of every include module; App sources are excluded
so an App-only edit keeps the cache. A mismatch deletes the cached objects and the build
recompiles them from the current sources with the current compiler.
should_zext_widen decides the widening direction from the narrow side's AST
node, but the return and assignment coercions passed the statement node.
A statement has no type of its own, so the literal rule and the annotated
variable rule were skipped and the value fell through to sext:
`def f() -> uint64: return 0xDEADBEEF` produced 0xFFFFFFFFDEADBEEF and the
caller compared against 0xDEADBEEF and failed.
Unwrap Return/Assign/AnnAssign/AugAssign/Expr to their value expression
before the direction rules run, so a non-negative literal wider than
INT32_MAX zero-extends (0xDEADBEEF -> 0xDEADBEEF) and a negative one keeps
its sign (-1 -> 0xFFFFFFFFFFFFFFFF).
A uint16 value that is widened to int/uint32/uint64 must be zero-extended
(movzwl), and an int16 value must be sign-extended (movswl). coerce_to_type
picks the direction from the source AST node's declared signedness, which
requires the translator; several call sites omitted it and therefore fell
back to sext, so a big-endian uint16 such as 0xDCBC became 0xFFFF...DCBC
(numGlyphs 56508 read as -9028 and every glyph range collapsed to zero).
- pass trans at the assignment, annotated-assignment, augmented-assignment,
global-array initializer, index, element-store, constructor-argument,
conditional-expression and return coercion sites
- printf argument promotion now receives the argument expression and trans,
so uint16/uint8 promote with zext like any other widening
- print selects the format token and extension from the source signedness
(%u/%llu + zext for unsigned, %d/%lld + sext for signed) instead of a
hardcoded %d + sext for 8/16-bit values
Adds LspSigCache: per-file signature cache under <project>/.viperc-lsp-cache, keyed by content hash+size, invalidated on didChange/didSave; resolves from-import, module-attribute and wildcard calls without building the workspace index. Extends LspTypes with list/array/pointer structural checks, array/string initializer lengths and list-literal element checks; adds return-type consistency, augmented-assignment narrowing, constant folding for unary/binop and string/list subscripts. Diagnostics use a per-line occurrence bucket, a dedicated cache work pool and statement/expression budgets so large include files stay responsive. gate13 covers the new rules.
Adds a conservative type model (LspTypes) and checks for annotated declarations, plain assignments to annotated names, and same-file function calls: implicit narrowing (including the compiler's constant bit-range exemption), bool narrowing, double->float, int<->ptr, float<->ptr, scalar->aggregate, str mismatch, plus argument count/type checks. Signatures are parsed from the existing symbol detail; cross-file calls are intentionally skipped to keep publish latency low. Diagnostics run in a scratch arena sized for analysis, with a 256-diagnostic cap; gate13 covers the new rules and the repo corpus stays clean.
Adds the first semantic rule: a function with a non-void return annotation that can reach the end without return/raise is reported at the return annotation. If/else must both exit; bare return is accepted (compiler parity); ... stubs, extern declarations and .vpi files are skipped. The annotation span is recovered from the header so int*/[]int/const int* are underlined completely. Semantic checks run only on syntactically clean files to avoid cascades. New probe --gate13 covers 13 cases plus a corpus check.
initializeOptions.disablePullDiagnostics now hides diagnosticProvider and keeps push diagnostics, matching the extension setting. gate2 verifies push and pull both clear the overflow diagnostic after the literal is fixed, and that the disable option falls back to push.
\rom mod import name as alias\ 原来只为别名生成 occurrence,原始名 name 没有 token。现在原始名和别名各生成一个 occurrence(按源码顺序),两者都解析到被导入的声明;\import a.b as c\ 仍只标注别名,模块路径保持 namespace。gate10 增加原始名/别名/使用处的 token 与定义断言。