Control Flow Graph
JustJIT builds a control flow graph (CFG) from Python bytecode to handle complex control flow. This is essential for proper LLVM IR generation, especially at merge points where PHI nodes are needed.
Why a CFG?
Python bytecode has complex control flow:
Conditional jumps (
if/else)Loops (
for,while)Exception handlers (
try/except)Pattern matching (
match/case)
When multiple code paths merge, LLVM’s SSA form requires PHI nodes to select between values from different predecessors. The CFG tells us where these merge points are.
Data Structures
JustJIT uses these data structures (defined in jit_core.h):
BasicBlockInfo
struct BasicBlockInfo {
int start_offset; // Bytecode offset where block starts
int end_offset; // Bytecode offset where block ends
std::vector<int> predecessors; // Blocks that jump here
std::vector<int> successors; // Blocks we jump to
int stack_depth_at_entry; // Expected stack depth
bool is_exception_handler; // True if exception handler entry
bool needs_phi_nodes; // True if multiple predecessors
llvm::BasicBlock* llvm_block; // The LLVM basic block
};
We use std::map<int, BasicBlockInfo> keyed by start offset.
Why std::map? Maps provide O(log n) lookup by offset and maintain sorted order, which helps when iterating blocks in bytecode order. For typical Python functions (tens to hundreds of instructions), this is efficient enough.
CFGStackState
struct CFGStackState {
std::vector<llvm::Value*> stack; // Values on stack
llvm::BasicBlock* from_block; // Source block
int from_offset; // Source offset
};
This captures stack state when leaving a block, enabling PHI node creation at merge points.
Three-Phase Analysis
CFG construction happens in three phases (lines 1102-1500 in jit_core.cpp):
Phase 1: Find Block Starts
The find_block_starts() function identifies basic block boundaries:
static std::set<int> find_block_starts(
const std::vector<Instruction>& instructions,
const std::vector<ExceptionTableEntry>& exception_table
) {
std::set<int> block_starts;
block_starts.insert(0); // Entry block always at offset 0
for (size_t i = 0; i < instructions.size(); ++i) {
const auto& instr = instructions[i];
// Conditional jumps create two block starts
if (instr.opcode == op::POP_JUMP_IF_FALSE ||
instr.opcode == op::POP_JUMP_IF_TRUE ||
instr.opcode == op::POP_JUMP_IF_NONE ||
instr.opcode == op::POP_JUMP_IF_NOT_NONE) {
block_starts.insert(instr.argval); // Jump target
if (i + 1 < instructions.size()) {
block_starts.insert(instructions[i+1].offset); // Fall-through
}
}
// Unconditional jumps
else if (instr.opcode == op::JUMP_FORWARD ||
instr.opcode == op::JUMP_BACKWARD) {
block_starts.insert(instr.argval);
}
// FOR_ITER has two exits
else if (instr.opcode == op::FOR_ITER) {
block_starts.insert(instr.argval); // Exhaustion target
if (i + 1 < instructions.size()) {
block_starts.insert(instructions[i+1].offset); // Continue
}
}
}
// Exception handlers are block starts
for (const auto& exc_entry : exception_table) {
block_starts.insert(exc_entry.target);
}
return block_starts;
}
Why std::set? Sets provide automatic deduplication (important since multiple instructions may target the same offset) and sorted iteration.
Phase 2: Build CFG Structure
The build_cfg() function creates blocks and edges:
static std::map<int, BasicBlockInfo> build_cfg(
const std::vector<Instruction>& instructions,
const std::vector<ExceptionTableEntry>& exception_table,
const std::set<int>& block_starts
) {
std::map<int, BasicBlockInfo> cfg;
// Create all blocks first
std::vector<int> sorted_starts(block_starts.begin(), block_starts.end());
for (size_t b = 0; b < sorted_starts.size(); ++b) {
int start = sorted_starts[b];
BasicBlockInfo info;
info.start_offset = start;
info.end_offset = (b + 1 < sorted_starts.size())
? sorted_starts[b + 1]
: instructions.back().offset + 2;
info.stack_depth_at_entry = -1; // Unknown initially
info.is_exception_handler = false;
info.needs_phi_nodes = false;
cfg[start] = info;
}
// Mark exception handlers
for (const auto& exc_entry : exception_table) {
if (cfg.count(exc_entry.target)) {
cfg[exc_entry.target].is_exception_handler = true;
cfg[exc_entry.target].stack_depth_at_entry = exc_entry.depth;
}
}
// Build edges (predecessors/successors)
// ... analyze each block's terminator instruction
return cfg;
}
Phase 3: Compute Stack Depths
The compute_stack_depths() function uses forward dataflow analysis:
bool compute_stack_depths(
std::map<int, BasicBlockInfo>& cfg,
const std::vector<Instruction>& instructions,
int initial_stack_depth = 0
) {
// Initialize entry block
cfg[0].stack_depth_at_entry = initial_stack_depth;
// Worklist algorithm
std::queue<int> worklist;
worklist.push(0);
while (!worklist.empty()) {
int offset = worklist.front();
worklist.pop();
BasicBlockInfo& block = cfg[offset];
int depth = block.stack_depth_at_entry;
// Simulate instructions in this block
for (each instruction in [block.start, block.end)) {
depth += stack_effect(instr);
}
// Propagate to successors
for (int succ : block.successors) {
if (cfg[succ].stack_depth_at_entry == -1) {
cfg[succ].stack_depth_at_entry = depth;
worklist.push(succ);
} else if (cfg[succ].stack_depth_at_entry != depth) {
// Inconsistent depths - this indicates a bug
return false;
}
}
}
return true;
}
Exception Handling
Exception handlers require special CFG treatment:
struct ExceptionTableEntry {
int32_t start; // Start of protected range
int32_t end; // End of protected range
int32_t target; // Handler entry point
int32_t depth; // Stack depth at handler entry
bool lasti; // Push last instruction offset?
};
The exception table is parsed from Python’s co_exceptiontable (line 1536):
for (size_t i = 0; i < py_exception_table.size(); ++i) {
nb::dict entry_dict = nb::cast<nb::dict>(py_exception_table[i]);
ExceptionTableEntry entry;
entry.start = nb::cast<int32_t>(entry_dict["start"]);
entry.end = nb::cast<int32_t>(entry_dict["end"]);
entry.target = nb::cast<int32_t>(entry_dict["target"]);
entry.depth = nb::cast<int32_t>(entry_dict["depth"]);
entry.lasti = nb::cast<bool>(entry_dict["lasti"]);
exception_table.push_back(entry);
}
Pattern Matching
Python 3.10+ pattern matching creates complex CFG patterns. JustJIT supports these opcodes:
MATCH_SEQUENCE: Check if subject is a sequenceMATCH_MAPPING: Check if subject is a mappingMATCH_CLASS: Match against class and extract attributesMATCH_KEYS: Extract values for specific keys
The JITMatchClass helper (line 271 in jit_core.cpp) handles class matching:
extern "C" PyObject* JITMatchClass(
PyObject *subject, PyObject *cls,
int nargs, PyObject *names
) {
// Check isinstance
int is_instance = PyObject_IsInstance(subject, cls);
if (!is_instance) {
Py_INCREF(Py_None);
return Py_None; // No match
}
// Get __match_args__ for positional patterns
if (nargs > 0) {
PyObject *match_args = PyObject_GetAttrString(cls, "__match_args__");
// Extract positional attributes using match_args
}
// Extract keyword attributes from 'names' tuple
// ...
return attrs; // Tuple of matched values
}
PHI Node Generation
At blocks with multiple predecessors, JustJIT generates PHI nodes:
// In compile_function, when entering a block
if (block.predecessors.size() > 1) {
// Multiple incoming edges - need PHI nodes for stack values
for (int i = 0; i < stack_depth; i++) {
llvm::PHINode* phi = builder.CreatePHI(
ptr_type, block.predecessors.size()
);
for (auto& incoming : block_incoming_stacks[offset]) {
phi->addIncoming(incoming.stack[i], incoming.predecessor);
}
stack[i] = phi;
}
}
Performance
CFG analysis is lightweight:
Time complexity: O(n) where n = number of instructions
Typical time: < 5ms for functions up to 500 instructions
This is negligible compared to LLVM optimization and code generation.
Reference Counting in Generators
Generator stack persistence requires careful reference counting. When saving stack values across yields:
// Before yield: incref stack values being saved
for (size_t s = 0; s < current_stack_depth; s++) {
int slot_idx = nlocals + s;
llvm::Value* slot_ptr = builder.CreateGEP(ptr_type, locals_array,
llvm::ConstantInt::get(i64_type, slot_idx));
builder.CreateCall(py_xincref_func, {stack[s]}); // Keep alive
builder.CreateStore(stack[s], slot_ptr);
}
On resume, stack values are loaded but not incref’d (they already have refs from save).
Exception Unwinding:
When a generator encounters an error (state = -2), remaining stack values must be decref’d to prevent leaks:
// In error block: decref all stack values
builder.SetInsertPoint(error_block);
for (size_t s = 0; s < max_stack_depth; s++) {
int slot_idx = nlocals + s;
llvm::Value* slot_ptr = builder.CreateGEP(ptr_type, locals_array,
llvm::ConstantInt::get(i64_type, slot_idx));
llvm::Value* val = builder.CreateLoad(ptr_type, slot_ptr);
builder.CreateCall(py_xdecref_func, {val});
builder.CreateStore(null_ptr, slot_ptr); // Clear slot
}
builder.CreateStore(llvm::ConstantInt::get(i32_type, -2), state_ptr);
builder.CreateRet(null_ptr);
This matches Python’s behavior of cleaning up on generator finalization.