Conversation
This is working for eval now, but not yet sorted for AOT modules.
This cuts clojure.core eval time from 1m48s to 1m6s.
|
@copilot I'm seeing some premature collections from the GC with these changes. Generally, this happens when there are JIT compiled globals which haven't been added as roots. However, I believe I have addressed all of those. In fact, we no longer JIT compile globals, since we reference existing objects in memory by address (and add them as roots for safety). Please investigate this. |
There was a problem hiding this comment.
Pull request overview
This PR refactors the C++ codegen/JIT pipeline to emit one extern "C" function per arity (instead of generating a per-fn C++ struct), then wires those symbols into a single jit_function/jit_closure object. This aligns the C++ codegen approach with the LLVM IR codegen path and significantly reduces JIT compile time and memory usage.
Changes:
- Replace struct-based C++ codegen with per-arity
extern "C"functions and runtime wiring viajit_prc.create_function(...). - Update lazy compilation (
deferred_cpp_function) and eager eval to use the new function-wiring path (no moreexpression_str() + ".erase().data"evaluation). - Introduce eval-mode global dedupe for lifted constants/vars and add
jit_closuresupport where applicable.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| compiler+runtime/src/cpp/jank/runtime/obj/deferred_cpp_function.cpp | Lazy C++ compilation now creates a jit_function via create_function instead of evaluating an expression to instantiate a struct. |
| compiler+runtime/include/cpp/jank/runtime/obj/deferred_cpp_function.hpp | Updates deferred fn state to store arity metadata needed for create_function. |
| compiler+runtime/src/cpp/jank/jit/processor.cpp | Adds eval(codegen::processor&) and create_function(...) to wire arity symbols into jit_function. |
| compiler+runtime/include/cpp/jank/jit/processor.hpp | Declares new JIT processor APIs used by eval and deferred compilation. |
| compiler+runtime/src/cpp/jank/evaluate.cpp | Eager eval now uses jit_prc.eval(cg_prc); lazy def stores arity info for later creation. |
| compiler+runtime/src/cpp/jank/codegen/processor.cpp | Major rewrite: emits per-arity C functions, adds closure context struct generation, adds eval-mode global constant/var dedupe. |
| compiler+runtime/include/cpp/jank/codegen/processor.hpp | Adds arity_flags() and expression_str(builder&); tracks closure_ctx; removes module footer buffer. |
| compiler+runtime/src/cpp/jank/codegen/llvm_processor.cpp | Updates compilation target enum usage (module_function). |
| compiler+runtime/include/cpp/jank/codegen/llvm_processor.hpp | Renames compilation target from function to module_function. |
| compiler+runtime/src/cpp/jank/c_api.cpp | Updates C API arity function pointer types to use object_ref instead of raw object*. |
| compiler+runtime/include/cpp/jank/runtime/obj/jit_function.hpp | Updates stored arity function pointer types to object_ref (...). |
| compiler+runtime/include/cpp/jank/runtime/obj/jit_closure.hpp | Updates stored arity function pointer types to object_ref (...) for closures. |
| compiler+runtime/src/cpp/jank/analyze/processor.cpp | Namespacing tweaks for unique fn names; improves let error usage reporting; adjusts let binding frame; changes try/catch type promotion behavior. |
| compiler+runtime/src/cpp/jank/analyze/local_frame.cpp | Marks captured bindings as captures (is_capture = true). |
| compiler+runtime/include/cpp/jank/analyze/local_frame.hpp | Replaces old shadowing TODO with explicit is_capture flag. |
| compiler+runtime/src/cpp/jank/analyze/cpp_util.cpp | Adds include-guard wrapper for generated inline literal functions; munges alias for macro safety. |
| compiler+runtime/src/cpp/clojure/core_native.cpp | Treats jit_closure as a function in is_fn. |
| compiler+runtime/include/cpp/jtl/ref.hpp | Marks make_ref and static_ref_cast as constexpr. |
| compiler+runtime/doc/build.md | Adds Boost dependencies to package install instructions. |
| .github/workflows/build-compiler+runtime.yml | Bumps package-cache version due to dependency list changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| body_buffer, | ||
| "extern \"C\" jank::runtime::object_ref {}_{}(jank::runtime::object_ref const {}", | ||
| struct_name, | ||
| arity.params.size(), | ||
| param_shadows_fn ? "" : runtime::munge(root_fn->name)); |
There was a problem hiding this comment.
When a parameter name matches root_fn->name, this emits an unnamed first parameter (object_ref const with no identifier). Later codegen still references runtime::munge(root_fn->name) to access the function object (e.g., for closure context / recursion), which will be undefined in that case and cause generated C++ to fail to compile.
Give the first parameter a stable, non-conflicting name (e.g. self/fn_obj) and consistently use that identifier wherever the function object is needed, rather than conditionally omitting the name.
| "closure>({})->context) };", | ||
| closure_ctx, | ||
| closure_ctx, | ||
| runtime::munge(root_fn->name)); |
There was a problem hiding this comment.
This closure-context extraction hard-codes runtime::munge(root_fn->name) as the function-object expression. If the first parameter was emitted without a name (see param_shadows_fn handling), this identifier doesn't exist and the generated function won't compile.
Use the same stable first-parameter identifier here (and in recursion references) instead of relying on root_fn->name being available as a variable.
| runtime::munge(root_fn->name)); | |
| fn_param_name); |
| *root = reinterpret_cast<runtime::var *>(var.data); | ||
| auto const fmt_str{ util::format("reinterpret_cast<jank::runtime::var*>({})", | ||
| static_cast<void *>(var.data)) }; | ||
| locked_global_vars->emplace(qualified_name, fmt_str); |
There was a problem hiding this comment.
In detail::lift_var, the target == compilation_target::eval branch builds and stores fmt_str (a direct reinterpret_cast<var*> expression) but never returns it. The function falls through to generating a unique lifted var name, which defeats the eval-mode dedupe/fast-path and contradicts the intent of global_vars.
Return fmt_str immediately after emplacing it (similar to lift_constant), and skip adding an entry to lifted_vars for eval mode so generated code consistently uses the direct pointer expression.
| locked_global_vars->emplace(qualified_name, fmt_str); | |
| locked_global_vars->emplace(qualified_name, fmt_str); | |
| return fmt_str; |
Overview
Previously, given some Clojure code like this:
We would generate some C++ like this:
We would also generate something like this to instantiate it:
Now, we just generate functions per arity, the same as we do with LLVM IR gen. Since each function has the same type
jit_function, we can skip on the second bit of generation and build all of that without more JIT compilation, which drastically reduces compile times and memory usage.Results
maincppgen./bin/compilejank repl