ClickHouse JIT-Compiles Scalar Expressions with LLVM

ClickHouse compiles hot scalar expressions to native code with LLVM, turning a per-node interpreter into a memory-bound loop over raw column pointers.

TL;DR: ClickHouse compiles hot scalar expressions to native code with LLVM. The path is: ActionsDAG → a three-node CompileDAG → hand-built IR → LLVM’s O3 pipeline → an RX page → a tight loop over raw column pointers. It is on by default since 25.5.

Every columnar database pays the same tax: a SELECT is not a loop over rows, it is a graph of actions, and every action dispatches through a virtual call per block.

ClickHouse’s block is 65 409 rows by default, so that dispatch overhead is paid 65 409 times less often than in a row-at-a-time engine. But for a narrow expression like a + b > c, the arithmetic itself is a couple of instructions per row — and the interpreter’s per-node costs (a virtual dispatch, a column-type switch, a ColumnPtr copy) dominate.

ClickHouse’s answer is a JIT compiler that is on by default since 25.5:

cpp
// src/Core/Settings.cpp:1459
    DECLARE(Bool, compile_expressions, true, R"(
Compile some scalar functions and operators to native code.
)", 0, \
        {"25.5", false, true, "We believe that the LLVM infrastructure behind the JIT compiler is stable enough to enable this setting by default."}) \

This article follows the whole path: from an ActionsDAG node, through a small intermediate representation (CompileDAG), into hand-built LLVM IR, through LLVM’s O3 pipeline, into an executable page, and finally into a tight loop that runs over raw column data.

The switch

The JIT is not free: compiling a module costs milliseconds. So ClickHouse only compiles an expression after it has been seen a few times. The threshold is a setting, default 3:

cpp
// src/Core/Settings.cpp:1463
    DECLARE(UInt64, min_count_to_compile_expression, 3, R"(
Minimum count of executing same expression before it is get compiled.
)", 0) \

The counter is a global hash map keyed by a hash of the expression DAG, so the “same expression” test is structural, not textual:

cpp
// src/Interpreters/ExpressionJIT.cpp:299
static FunctionBasePtr compile(
    const CompileDAG & dag,
    size_t min_count_to_compile_expression)
{
    static std::unordered_map<UInt128, UInt64, UInt128Hash> counter;
    static std::mutex mutex;

    auto hash_key = dag.hash();
    {
        std::lock_guard lock(mutex);
        if (counter[hash_key]++ < min_count_to_compile_expression)
            return nullptr;
    }

Note the ++ inside the comparison: the counter is incremented on every call, and the expression is compiled on the fourth sighting (with the default of 3). The counter never decays, so a hot expression stays hot forever.

The trigger point is the construction of ExpressionActions, which is where a DAG becomes an executable list of actions:

cpp
// src/Interpreters/ExpressionActions.cpp:74
#if USE_EMBEDDED_COMPILER
    if (settings.can_compile_expressions && settings.compile_expressions == CompileExpressions::yes)
        actions_dag.compileExpressions(settings.min_count_to_compile_expression, lazy_executed_nodes);
#endif

Note the ordering: short-circuit functions are analysed first and the set of “lazily executed” nodes is computed before compiling, because a node that is only evaluated under a condition must not be folded into a compiled straight-line expression.

CompileDAG: a tiny IR

ActionsDAG is the general-purpose graph ClickHouse uses for every expression: it carries column nodes, aliases, lambdas, arrays, and functions. It is too rich to translate to LLVM directly, so the JIT first flattens it into CompileDAG, which has exactly three kinds of node:

cpp
// src/Interpreters/JIT/CompileDAG.h:36
    enum class CompileType : uint8_t
    {
        INPUT = 0,
        CONSTANT = 1,
        FUNCTION = 2,
    };

The flattening is a post-order DFS over the ActionsDAG that stops at anything it cannot compile. getCompilableDAG in ExpressionJIT.cpp walks the DAG with an explicit stack and produces, for each candidate node, a CompileDAG plus the list of children that became inputs:

cpp
// src/Interpreters/ExpressionJIT.cpp:404
static CompileDAG getCompilableDAG(
    const ActionsDAG::Node * root,
    ActionsDAG::NodeRawConstPtrs & children,
    const std::unordered_set<const ActionsDAG::Node *> & lazy_executed_nodes)
{
    /// Extract CompileDAG from root actions dag node.

    CompileDAG dag;

    std::unordered_map<const ActionsDAG::Node *, size_t> visited_node_to_compile_dag_position;

A node is a leaf of the compiled fragment when it is a constant, when it is not compilable at all, or when its function declares that the argument does not participate in compilation:

cpp
// src/Interpreters/ExpressionJIT.cpp:430
        bool is_compilable_constant = isCompilableConstant(*node);
        bool is_compilable_function = isCompilableFunction(*node, lazy_executed_nodes);

        if (!is_compilable_function || is_compilable_constant || frame.skip_compile)
        {
            CompileDAG::Node compile_node;
            compile_node.function = node->function_base;
            compile_node.result_type = node->result_type;

            if (is_compilable_constant)
            {
                compile_node.type = CompileDAG::CompileType::CONSTANT;
                compile_node.column = node->column;
            }
            else if (frame.skip_compile)
            {
                compile_node.type = CompileDAG::CompileType::CONSTANT;
                compile_node.column = node->column;
                compile_node.skip_compile = true;
            }
            else
            {
                compile_node.type = CompileDAG::CompileType::INPUT;
                children.emplace_back(node);
            }

The skip_compile flag is how “arguments that don’t participate” get handled: the argument is still passed to the function at runtime (the C++ function signature has all its arguments), but its value is not computed by the compiled code. This is what makes if(cond, f(x), g(x))-style functions compile at all: the lazily evaluated branch is a runtime argument, not a compiled computation.

Choosing what to compile

ActionsDAG::compileFunctions is the part that decides which nodes become compiled fragments. It is not “compile everything”: it computes, for each node, how many compilable descendants it has, and only compiles nodes that have at least one compilable child and whose parents are not themselves compilable (so a whole subtree is compiled once, at its root, not piece by piece):

cpp
// src/Interpreters/ExpressionJIT.cpp:594
    for (const auto & node : nodes)
    {
        auto & node_data = node_to_data[&node];
        bool node_is_valid_for_compilation = node_data.is_compilable_in_isolation && node_data.compilable_children_size > 0;

        for (const auto & child : node.children)
            node_to_data[child].all_parents_compilable &= node_is_valid_for_compilation;
    }

    for (const auto & output_node : outputs)
    {
        /// Force output nodes to compile
        node_to_data[output_node].all_parents_compilable = false;
    }

Then the fragments are sorted so that a parent is compiled before its children — otherwise the same subexpression would be compiled twice under two different names:

cpp
// src/Interpreters/ExpressionJIT.cpp:626
    /** Sort nodes before compilation using their children size to avoid compiling subexpression before compile parent expression.
      * This is needed to avoid compiling expression more than once with different names because of compilation order.
      */
    ::sort(nodes_to_compile.begin(), nodes_to_compile.end(), [&](const Node * lhs, const Node * rhs)
    {
        return node_to_data[lhs].children_size > node_to_data[rhs].children_size;
    });

and the compiled node replaces the original in place:

cpp
// src/Interpreters/ExpressionJIT.cpp:649
            node->type = ActionsDAG::ActionType::FUNCTION;
            node->function_base = fn;
            node->function = fn->prepare(arguments);
            node->children.swap(new_children);
            node->is_function_compiled = true;
            node->column = nullptr;

From CompileDAG to LLVM IR

CompileDAG::compile emits the IR for the fragment. For a function node it recursively materialises the argument values and asks the function itself to emit its own IR:

cpp
// src/Interpreters/JIT/CompileDAG.cpp:45
            case CompileType::FUNCTION:
            {
                ValuesWithType temporary_values;
                temporary_values.reserve(node.arguments.size());
                for (auto argument_index : node.arguments)
                    temporary_values.emplace_back(compiled_values[argument_index]);

                ValueWithType compiled_value{node.function->compile(builder, temporary_values), node.function->getResultType()};
                if (!node.result_type->equals(*node.function->getResultType()))
                    compiled_values[compiled_values_index] = {nativeCast(b, compiled_value, node.result_type), node.result_type};
                else
                    compiled_values[compiled_values_index] = std::move(compiled_value);

                break;
            }

Constants are baked into the module as native values, inputs are the loop’s loaded values, and functions call IFunction::compile.

The function contract

Each SQL function that wants to be JIT-compilable implements compileImpl, which receives the already-typed argument values as LLVM values and returns an LLVM value. A binary arithmetic function looks like this:

cpp
// src/Functions/FunctionBinaryArithmetic.h:3821
    llvm::Value * compileImpl(llvm::IRBuilderBase & builder, const ValuesWithType & arguments, const DataTypePtr & result_type) const override
    {
        chassert(2 == arguments.size());

        auto denull_left_type = removeNullable(arguments[0].type);
        auto denull_right_type = removeNullable(arguments[1].type);
        llvm::Value * result = nullptr;

and ends with the actual operation, dispatched on the native type:

cpp
// src/Functions/FunctionBinaryArithmetic.h:3846
                        auto & b = static_cast<llvm::IRBuilder<> &>(builder);
                        auto * lval = nativeCast(b, arguments[0], result_type);
                        auto * rval = nativeCast(b, arguments[1], result_type);
                        result = OpSpec::compile(b, lval, rval, is_signed_v<typename ResultDataType::FieldType>);
                        return true;

NULLs: the ternary trick

Nullable(T) is represented in IR as a two-element struct { T value, UInt8 is_null }. Handling NULLs in compiled code is where most of the complexity lives, because ClickHouse’s default implementation for NULLs is “if any argument is NULL, the result is NULL” — which is not what and/or want (three-valued logic).

The framework’s answer is to compile the denulled function and then OR the null flags back in:

cpp
// src/Functions/IFunction.cpp:1281
    auto denulled_arguments_types = removeNullables(arguments_types);
    if (useDefaultImplementationForNulls() && denulled_arguments_types)
    {
        auto & b = static_cast<llvm::IRBuilder<> &>(builder);

        ValuesWithType unwrapped_arguments;
        unwrapped_arguments.reserve(arguments.size());

        VectorWithMemoryTracking<llvm::Value *> is_null_values;

        auto skip_arguments = getArgumentsThatDontParticipateInCompilation(arguments_types);
        for (size_t i = 0; i < arguments.size(); ++i)
        {
            const auto & argument = arguments[i];
            llvm::Value * unwrapped_value = argument.value;

            bool skip_compile = (std::find(skip_arguments.begin(), skip_arguments.end(), i) != skip_arguments.end());
            if (argument.type->isNullable())
            {
                unwrapped_value = skip_compile ? nullptr : b.CreateExtractValue(argument.value, {0});
                is_null_values.emplace_back(skip_compile ? nullptr : b.CreateExtractValue(argument.value, {1}));
            }

            unwrapped_arguments.emplace_back(unwrapped_value, (*denulled_arguments_types)[i]);
        }

and then, at the end, the null flags are merged into the result:

cpp
// src/Functions/IFunction.cpp:1317
            for (auto * is_null_value : is_null_values)
            {
                if (is_null_value)
                    nullable_structure_result_null = b.CreateOr(nullable_structure_result_null, is_null_value);
            }

            return b.CreateInsertValue(nullable_structure_with_result_value, nullable_structure_result_null, {1});

Functions with special NULL semantics (the logical ones) opt out of this and do the work themselves, using an explicit three-valued encoding:

cpp
// src/Functions/FunctionsLogical.h:267
    llvm::Value * compileImpl(llvm::IRBuilderBase & builder, const ValuesWithType & values, const DataTypePtr & result_type) const override
    {
        chassert(!values.empty());

        auto & b = static_cast<llvm::IRBuilder<> &>(builder);
        if constexpr (Impl::specialImplementationForNulls())
        {
            if (result_type->isNullable())
            {
                /// First we need to cast all values to ternary logic
                llvm::Value * ternary_result = nativeTernaryCast(b, values[0]);
                for (size_t i = 1; i < values.size(); ++i)
                {
                    llvm::Value * casted_value = nativeTernaryCast(b, values[i]);
                    ternary_result = Impl::ternaryApply(b, ternary_result, casted_value);
                }

                /// Then transform ternary logic to struct which represents nullable result
                llvm::Value * is_null = b.CreateICmpEQ(ternary_result, b.getInt8(Ternary::Null));
                llvm::Value * is_true = b.CreateICmpEQ(ternary_result, b.getInt8(Ternary::True));

This is a nice illustration of the general ClickHouse pattern: the interpreter path handles NULLs with a nested column representation and a mask, the compiled path handles them with a struct and a select — the semantics are the same, the representation is not.

The generated loop

The whole pipeline, from a plan node to a running loop:

text
   ActionsDAG node                      CompileDAG (3 node kinds)
   (Function, Column, Alias, ...)        INPUT     column to read
        |                               CONSTANT  baked-in literal
        |  getCompilableDAG()            FUNCTION  nested call
        v
   CompileDAG  --hash()-->  SipHash-128  --min_count_to_compile_expression--> ?
        |                                          (seen 4 times?)
        v  compile()
   llvm::Module  (IRBuilder: entry / loop / end blocks)
        |
        v  O3 pipeline + RuntimeDyld
   executable page (PROT_READ|PROT_EXEC)  <---- CompiledExpressionCache (weighted by code size)
        |
        v  callJITFunction(rows, columns)
   for (i = 0; i < rows; ++i) { result[i] = expr(columns[0][i], columns[1][i]); }

The IR builder in compileFunction.cpp wraps the expression in a loop over the rows. The function’s ABI is void f(size_t rows, ColumnData * columns), where ColumnData is a { const char * data, const char * null_data, const char * offsets } triple — the raw pointers of the column, its null map, and (for strings) its offsets:

cpp
// src/Interpreters/JIT/compileFunction.cpp:88
static llvm::StructType * buildColumnDataStruct(llvm::IRBuilderBase & b)
{
    auto * char_ptr_type = b.getInt8Ty()->getPointerTo();
    return llvm::StructType::get(char_ptr_type, char_ptr_type, char_ptr_type);
}

The function itself is created with ExternalLinkage so the linker can find it by name later, and the loop is a textbook phi-node counter:

cpp
// src/Interpreters/JIT/compileFunction.cpp:139
    /// Initialize loop

    auto * end = llvm::BasicBlock::Create(b.getContext(), "end", func);
    auto * loop = llvm::BasicBlock::Create(b.getContext(), "loop", func);
    b.CreateCondBr(b.CreateICmpEQ(rows_count_arg, llvm::ConstantInt::get(size_type, 0)), end, loop);

    b.SetInsertPoint(loop);

    /// Loop

    auto * counter_phi = b.CreatePHI(rows_count_arg->getType(), 2);
    counter_phi->addIncoming(llvm::ConstantInt::get(size_type, 0), entry);

Each argument is loaded from the column data with an in-bounds GEP on the row index:

cpp
// src/Interpreters/JIT/compileFunction.cpp:162
        auto * column_data_ptr = column.data_ptr;
        auto * column_element_value = b.CreateLoad(column.data_element_type, b.CreateInBoundsGEP(column.data_element_type, column_data_ptr, counter_phi));

        if (!type->isNullable())
        {
            arguments.emplace_back(column_element_value, type);
            continue;
        }

        auto * column_is_null_element_value = b.CreateLoad(b.getInt8Ty(), b.CreateInBoundsGEP(b.getInt8Ty(), column.null_data_ptr, counter_phi));
        auto * is_null = b.CreateICmpNE(column_is_null_element_value, b.getInt8(0));
        auto * nullable_unitialized = llvm::Constant::getNullValue(toNullableType(b, column.data_element_type));
        auto * nullable_value = b.CreateInsertValue(b.CreateInsertValue(nullable_unitialized, column_element_value, {0}), is_null, {1});
        arguments.emplace_back(nullable_value, type);

and the result is stored back into the output column, which is passed as the last element of the columns array:

cpp
// src/Interpreters/JIT/compileFunction.cpp:184
    auto * result_column_element_ptr = b.CreateInBoundsGEP(columns.back().data_element_type, columns.back().data_ptr, counter_phi);

    if (columns.back().null_data_ptr)
    {
        b.CreateStore(b.CreateExtractValue(result, {0}), result_column_element_ptr);
        auto * result_column_is_null_element_ptr = b.CreateInBoundsGEP(b.getInt8Ty(), columns.back().null_data_ptr, counter_phi);
        auto * is_result_column_element_null = b.CreateSelect(b.CreateExtractValue(result, {1}), b.getInt8(1), b.getInt8(0));
        b.CreateStore(is_result_column_element_null, result_column_is_null_element_ptr);
    }
    else
    {
        b.CreateStore(result, result_column_element_ptr);
    }

Running it

At runtime, LLVMExecutableFunction::executeImpl allocates an output column, gathers the raw pointers, and calls into the compiled code:

cpp
// src/Interpreters/ExpressionJIT.cpp:99
    ColumnPtr executeImpl(const ColumnsWithTypeAndName & arguments, const DataTypePtr & result_type, size_t input_rows_count) const override
    {
        if (!canBeNativeType(*result_type))
            throw Exception(ErrorCodes::LOGICAL_ERROR, "LLVMExecutableFunction unexpected result type in: {}", result_type->getName());

        auto result_column = result_type->createUninitializedColumnWithSize(input_rows_count);

        if (input_rows_count)
        {
            std::vector<ColumnData> columns(arguments.size() + 1);
            Columns columns_backup;

            for (size_t i = 0; i < arguments.size(); ++i)
            {
                auto column = arguments[i].column->convertToFullColumnIfConst();
                columns_backup.emplace_back(column);
                columns[i] = getColumnData(column.get());
            }

            columns[arguments.size()] = getColumnData(result_column.get());

            auto jit_compiled_function = compiled_function_holder->compiled_function.compiled_function;
            callJITFunction(jit_compiled_function, input_rows_count, columns.data());

getColumnData is where the layout knowledge lives — a String column is not a contiguous array of values, it is a chars buffer plus an offsets buffer, and the JIT comparator indexes both directly:

cpp
// src/Interpreters/JIT/compileFunction.cpp:63
    /// skip null key data for one nullable key optimization
    if (WhichDataType(column->getDataType()).isString())
    {
        /// For String columns, use base pointers to chars and offsets arrays directly.
        /// The JIT comparator uses absolute row indices into these arrays, so both
        /// pointers must be to the start of their respective arrays regardless of skip_rows.
        const auto * string_column = assert_cast<const ColumnString *>(column);
        result.data = reinterpret_cast<const char *>(string_column->getChars().data());
        result.offset_data = reinterpret_cast<const char *>(string_column->getOffsets().data());
    }

For a String the “element type” is therefore a {chars*, offsets*, index} struct, not a char*:

cpp
// src/Interpreters/JIT/compileFunction.cpp:94
static llvm::StructType * buildStringRefType(llvm::IRBuilderBase &b)
{
    auto * data_ptr_type = b.getInt8Ty()->getPointerTo();
    auto * offset_ptr_type = b.getInt8Ty()->getPointerTo();
    auto * index_type = b.getInt64Ty();
    return llvm::StructType::get(data_ptr_type, offset_ptr_type, index_type);
}

The JIT itself

CHJIT is a small, self-contained LLVM JIT: it uses RuntimeDyld to link the object file into memory it manages itself.

The whole compilation is serialised by a mutex, because LLVM’s context is shared:

cpp
// src/Interpreters/JIT/CHJIT.cpp:480
CHJIT::CompiledModule CHJIT::compileModule(std::function<void (llvm::Module &)> compile_function)
{
    std::lock_guard lock(jit_lock);

The module is then run through a full O3 pipeline with SLP vectorisation enabled:

cpp
// src/Interpreters/JIT/CHJIT.cpp:620
void CHJIT::runOptimizationPassesOnModule(llvm::Module & module) const
{
    llvm::LoopAnalysisManager lam;
    llvm::FunctionAnalysisManager fam;
    llvm::CGSCCAnalysisManager cgam;
    llvm::ModuleAnalysisManager mam;

    llvm::PipelineTuningOptions pto;
    pto.SLPVectorization = true;

    /// Pass the actual TargetMachine so that all optimization passes
    /// (including target-specific ones) have proper target information.
    llvm::PassBuilder pb(machine.get(), pto);

    pb.registerModuleAnalyses(mam);
    pb.registerCGSCCAnalyses(cgam);
    pb.registerFunctionAnalyses(fam);
    pb.registerLoopAnalyses(lam);
    pb.crossRegisterProxies(lam, fam, cgam, mam);

    llvm::ModulePassManager mpm = pb.buildPerModuleDefaultPipeline(llvm::OptimizationLevel::O3);
    mpm.run(module, mam);
}

The target machine is the host’s, with the host CPU and features, so the compiled code uses whatever vector instructions the machine has:

cpp
// src/Interpreters/JIT/CHJIT.cpp:644
std::unique_ptr<llvm::TargetMachine> CHJIT::getTargetMachine()
{
    static std::once_flag llvm_target_initialized;
    std::call_once(llvm_target_initialized, []()
    {
        llvm::InitializeNativeTarget();
        llvm::InitializeNativeTargetAsmPrinter();
        llvm::sys::DynamicLibrary::LoadLibraryPermanently(nullptr);
    });

    std::string error;
    auto cpu = llvm::sys::getHostCPUName();
    auto triple = llvm::Triple(llvm::sys::getProcessTriple());

Memory management

JIT code needs executable pages. ClickHouse does not use mmap directly; it allocates page-aligned blocks with posix_memalign and flips their protection with mprotect once the linking is done:

cpp
// src/Interpreters/JIT/CHJIT.cpp:287
    void allocateNextPageBlock(size_t size)
    {
        size_t pages_to_allocate_size = ((size / page_size) + 1) * 2;
        size_t allocate_size = page_size * pages_to_allocate_size;

        void * buf = nullptr;
        int res = posix_memalign(&buf, page_size, allocate_size);

The double allocation (+1 then *2 pages) gives both slack for the alignment request and room to grow without a new mprotect dance. Three separate arenas exist — read-write data, read-only data, and executable code — and the memory manager marks them appropriately at the end:

cpp
// src/Interpreters/JIT/CHJIT.cpp:353
    bool finalizeMemory(std::string *) override
    {
        ro_page_arena.protect(PROT_READ);
        ex_page_arena.protect(PROT_READ | PROT_EXEC);
        return true;
    }

On ARM the code has to invalidate the instruction cache after mprotect, and some ARM CPUs fault if the page is not readable while the cache is being flushed — hence the special case:

cpp
// src/Interpreters/JIT/CHJIT.cpp:211
#    if defined(__arm__) || defined(__aarch64__)
            /// Certain ARM implementations treat icache clear instruction as a memory read,
            /// and CPU segfaults on trying to clear cache on !PROT_READ page.
            /// Therefore we need to temporarily add PROT_READ for the sake of flushing the instruction caches.
            if (invalidate_cache && !(protection_flags & PROT_READ))
            {
                int res = mprotect(block.base(), block.blockSize(), protection_flags | PROT_READ);

Symbol resolution

The compiled code may call back into ClickHouse (e.g. memcpy, or the 128-bit integer helpers for Int128 arithmetic). A JITSymbolResolver maps those names to addresses, and the trick of storing mangled names removes a platform-specific lookup path:

cpp
// src/Interpreters/JIT/CHJIT.cpp:397
    void registerSymbol(const std::string & symbol_name, void * symbol)
    {
        std::string mangled_name;
        llvm::raw_string_ostream mangled_name_stream(mangled_name);
        llvm::Mangler::getNameWithPrefix(mangled_name_stream, symbol_name, layout);
        mangled_name_stream.flush();
        symbol_name_to_symbol_address[mangled_name] = symbol;
    }

The 128-bit helpers come in signed/unsigned pairs, and the comment explains why both halves must be registered:

cpp
// src/Interpreters/JIT/CHJIT.cpp:76
/// IMPORTANT: every libcall is registered as a signed/unsigned pair. LLVM picks the
/// signed or unsigned variant based on whether the IR uses `fptosi`/`sitofp`/`sdiv`/`srem`
/// or `fptoui`/`uitofp`/`udiv`/`urem`. Forgetting the unsigned half compiles fine but
/// fails the JIT link at run time with `Could not find symbol __fixunsdfti` (or similar).
/// Keep the lists below symmetric.

Caching the compiled code

Compiling the same expression twice is pure waste, so compiled modules are cached by the CompileDAG hash:

cpp
// src/Interpreters/JIT/CompiledExpressionCache.h:37
class CompiledExpressionCache : public CacheBase<UInt128, CompiledExpressionCacheEntry, UInt128Hash, CompiledFunctionWeightFunction>
{
public:
    using Base = CacheBase<UInt128, CompiledExpressionCacheEntry, UInt128Hash, CompiledFunctionWeightFunction>;
    using Base::Base;
};

The cache is weight-aware: an entry’s weight is the number of bytes of machine code it occupies, so evicting a big module frees proportionally more memory.

The hash that keys the cache is a structural hash over the DAG:

cpp
// src/Interpreters/JIT/CompileDAG.cpp:126
UInt128 CompileDAG::hash() const
{
    SipHash hash;
    for (const auto & node : nodes)
    {
        hash.update(node.type);

        const auto & result_type_name = node.result_type->getName();
        hash.update(result_type_name.size());
        hash.update(result_type_name);

        switch (node.type)
        {
            case CompileType::CONSTANT:
            {
                node.column->getDataColumn().updateHashWithValue(0, hash);
                break;
            }

so two syntactically different but semantically identical expressions hash the same, and the same expression with a different constant does not.

Beyond scalar expressions

The same machinery compiles two more things.

Aggregate functions. A whole aggregation is compiled as five entry points:

cpp
// src/Interpreters/JIT/compileFunction.cpp:544
CompiledAggregateFunctions compileAggregateFunctions(CHJIT & jit, const std::vector<AggregateFunctionWithOffset> & functions, std::string functions_dump_name)
{
    Stopwatch watch;

    std::string create_aggregate_states_functions_name = functions_dump_name + "_create";
    std::string add_aggregate_states_functions_name = functions_dump_name + "_add";
    std::string add_aggregate_states_functions_name_single_place = functions_dump_name + "_add_single_place";
    std::string merge_aggregate_states_functions_name = functions_dump_name + "_merge";
    std::string insert_aggregate_states_functions_name = functions_dump_name + "_insert";

_create builds the aggregate states, _add folds a row into them, _merge merges two states, _insert writes the final values into the result columns. The _add function is generated twice: once for the “many places” layout (a hash table of aggregate states) and once for the “single place” layout (one state per group):

cpp
// src/Interpreters/JIT/compileFunction.cpp:556
        compileCreateAggregateStatesFunctions(module, functions, create_aggregate_states_functions_name);
        compileAddIntoAggregateStatesFunctions(module, functions, add_aggregate_states_functions_name, AddIntoAggregateStatesPlacesArgumentType::MultiplePlaces);
        compileAddIntoAggregateStatesFunctions(module, functions, add_aggregate_states_functions_name_single_place, AddIntoAggregateStatesPlacesArgumentType::SinglePlace);
        compileMergeAggregatesStates(module, functions, merge_aggregate_states_functions_name);
        compileInsertAggregatesIntoResultColumns(module, functions, insert_aggregate_states_functions_name);

Sort comparators. A multi-column sort key is compiled into a single comparator function that returns int8. The IR is a chain of basic blocks, one per sort column, joined by a phi node — a hand-written decision tree:

cpp
// src/Interpreters/JIT/compileFunction.cpp:742
        /** 1. If it is last condition block move to join block.
          * 2. If column elements are not equal move to join block.
          * 3. If column elements are equal move to next column condition.
          */
        if (i == columns_size - 1)
            b.CreateBr(comparator_join);
        else
            b.CreateCondBr(b.CreateICmpEQ(result, lhs_equals_rhs_result), comparator_steps_and_results[i + 1].first, comparator_join);

and the function’s return attribute is not a detail but a correctness requirement on Apple’s arm64 ABI:

cpp
// src/Interpreters/JIT/compileFunction.cpp:608
    /// The result is read back through an `int8_t (*)(...)` function pointer, so the i8 return
    /// must obey the C `int8_t` calling convention. This matters only on Darwin/Apple arm64:
    /// Apple's arm64 ABI makes the *callee* sign-extend sub-word return values and the caller
    /// relies on it (it emits no `sxtb` after the call). Without `signext` the JIT callee leaves
    /// the upper bits unset, so e.g. a -1 result is read as 255 and NULL ordering breaks.
    /// Linux arm64 (AAPCS64) is unaffected because there the *caller* sign-extends the return
    /// itself (`sxtb w0, w0`); x86-64 is likewise a no-op.
    comparator_func->addRetAttr(llvm::Attribute::SExt);

Trade-offs

What the JIT buys: the per-block dispatch, the per-node type switches and the column representation overhead disappear for the compiled fragment; the loop is a plain memory-bound loop that the CPU can auto-vectorise. For a filter like WHERE user_id = 42 AND ts > now() - INTERVAL 1 HOUR, this is the difference between a few hundred nanoseconds per block and a few microseconds.

What it costs:

  • Memory: every compiled module occupies executable pages, and the cache is weight-limited by machine-code size. A server with thousands of distinct hot expressions holds a lot of RX pages.

  • Compile latency: the first four executions of an expression pay for the compile; the O3 pipeline is not cheap (hundreds of microseconds to milliseconds). The min_count_to_compile_expression threshold exists exactly to amortise this.

  • A second implementation of every function’s semantics: compileImpl and executeImpl must agree. Bugs where the JIT path and the interpreter path differ are a real class of ClickHouse bugs — the Nullable handling above is where most of the subtlety lives.

  • Restricted type coverage: only “native” types (fixed-width integers, floats, Date, DateTime, and String/FixedString through their special reference structs) can be compiled. Decimal is explicitly excluded from the arithmetic family:

    cpp
    // src/Functions/FunctionBinaryArithmetic.h:3840
                            if constexpr (
                                !std::is_same_v<ResultDataType, InvalidType> && !IsDataTypeDecimal<ResultDataType>
                                && !IsDataTypeDecimal<LeftDataType> && !IsDataTypeDecimal<RightDataType> && OpSpec::compilable)
  • Locking: CHJIT::compileModule holds a global mutex, so the compile of one expression blocks the compile of another. The compiled code is fully parallel — it is only the compilation that is serialised.

And the escape hatch is one setting:

sql
SELECT count() FROM events WHERE user_id = 42 SETTINGS compile_expressions = 0;

Wrapping up

The one thing to remember: the JIT removes interpretation overhead, not arithmetic. If your expression is already dominated by memory bandwidth, the JIT will not save you. If it is dominated by per-node dispatch, it will save a lot.

The part people get wrong: the first four executions of an expression are interpreted. A query that runs once, or a low-traffic tenant, gets no benefit at all from the JIT — you pay the compile latency and never amortise it.

What to go do about it: compare compile_expressions = 0 against the default on your hottest filter queries and look at the wall clock. If the difference is small, the interpreter was never your bottleneck and the real fix is a different query shape.

ClickhousePerformanceInternalsJitLlvm