Chapter 39Performance And Inlining

性能与内联

概述

我们的CLI调查为系统性实验奠定了基础。38 现在我们关注Zig如何将这些命令行开关转换为机器级行为。语义内联、调用修饰符和显式SIMD都为您提供控制热路径的杠杆——前提是您需要仔细测量并尊重编译器的默认值。#inline fn

下一章通过叠加性能分析和强化工作流来形式化这个测量循环。 40

学习目标

  • 当编译时语义必须优于启发式时,强制或禁止内联。
  • 使用@callstd.time.Timer对热循环进行采样,以比较构建模式。
  • 在使用目标特定内在函数之前,使用@Vector数学作为可移植SIMD的桥梁。

#call, Timer.zig, #vectors

语义内联与优化器启发式

Zig的inline关键字改变求值规则,而不是向优化器提示:编译时已知的参数成为编译时常量,允许您生成类型或预计算值,而普通调用会将其推迟到运行时。

内联函数限制编译器的自由度,因此仅在语义重要时才使用——传播comptime数据、改善调试或满足实际基准测试。

理解优化模式

在探索内联行为之前,理解影响编译器如何处理代码的优化模式非常重要。下图显示了优化配置:

graph TB subgraph "优化" OPTIMIZE["优化设置"] OPTIMIZE --> OPTMODE["optimize_mode: OptimizeMode<br/>Debug, ReleaseSafe, ReleaseFast, ReleaseSmall"] OPTIMIZE --> LTO["lto: bool<br/>链接时优化"] end

Zig提供四种不同的优化模式,每种都在安全性、速度和二进制大小之间做出不同的权衡。Debug模式禁用优化并保留完整的运行时安全检查,使其成为开发和调试的理想选择。编译器保留堆栈帧,发出符号信息,除非语义要求,否则永不内联函数。ReleaseSafe在保留所有安全检查(边界检查、整数溢出检测等)的同时启用优化,在性能与错误检测之间取得平衡。ReleaseFast通过禁用运行时安全检查并启用包括启发式内联在内的激进优化来最大化速度。这是本章整个基准测试中使用的模式。ReleaseSmall优先考虑二进制大小而不是速度,通常完全禁用内联以减少代码重复。

此外,链接时优化(LTO)可以通过-flto独立启用,允许链接器在编译单元之间执行整个程序优化。在对内联行为进行基准测试时,这些模式会显著影响结果:inline函数在所有模式中表现相同(语义保证),但ReleaseFast中的启发式内联可能会内联Debug或ReleaseSmall会保留为调用的函数。本章的示例使用-OReleaseFast来展示优化器行为,但您应该跨模式测试以了解完整的性能范围。

示例:内联函数的编译时数学计算

inline递归允许我们将小计算烘焙到二进制中,同时为较大输入保留后备运行时路径。@call内置函数提供了直接句柄,当参数可用时在编译时评估调用站点。

Zig

// This file demonstrates Zig's inline semantics and compile-time execution features.
// It shows how the `inline` keyword and `@call` builtin can control when and how
// functions are evaluated at compile-time versus runtime.

const std = @import("std");

/// Computes the nth Fibonacci number using recursion.
/// The `inline` keyword forces this function to be inlined at all call sites,
/// and the `comptime n` parameter ensures the value can be computed at compile-time.
/// This combination allows the result to be available as a compile-time constant.
inline fn fib(comptime n: usize) usize {
    return if (n <= 1) n else fib(n - 1) + fib(n - 2);
}

/// Computes the factorial of n using recursion.
/// Unlike `fib`, this function is not marked `inline`, so the compiler
/// decides whether to inline it based on optimization heuristics.
/// It can be called at either compile-time or runtime.
fn factorial(n: usize) usize {
    return if (n <= 1) 1 else n * factorial(n - 1);
}

// Demonstrates that an inline function with comptime parameters
// propagates compile-time execution to its call sites.
// The entire computation happens at compile-time within the comptime block.
test "inline fibonacci propagates comptime" {
    comptime {
        const value = fib(10);
        try std.testing.expectEqual(@as(usize, 55), value);
    }
}

// Demonstrates the `@call` builtin with `.compile_time` modifier.
// This forces the function call to be evaluated at compile-time,
// even though `factorial` is not marked `inline` and takes non-comptime parameters.
test "@call compile_time modifier" {
    const result = @call(.compile_time, factorial, .{5});
    try std.testing.expectEqual(@as(usize, 120), result);
}

// Verifies that a non-inline function can still be called at runtime.
// The input is a runtime value, so the computation happens during execution.
test "runtime factorial still works" {
    const input: usize = 6;
    const value = factorial(input);
    try std.testing.expectEqual(@as(usize, 720), value);
}
运行
Shell
$ zig test 01_inline_semantics.zig
输出
Shell
All 3 tests passed.

如果被调用方触及仅运行时状态,.compile_time修饰符将失败。首先将此类实验包装在comptime块中,然后添加运行时测试,以确保发布构建仍然被覆盖。

指导测量调用

Zig 0.15.2的自托管后端奖励精确的微基准测试。当与新的线程化代码生成管道配合使用时,它们可以实现显著的性能提升。v0.15.2

使用@call修饰符来比较内联、默认和永不内联的分发,而无需重构您的调用站点。

示例:在ReleaseFast下比较调用修饰符

此基准测试固定优化器(-OReleaseFast),同时切换调用修饰符。每个变体产生相同的结果,但时间测量突出了当函数调用开销占主导时,never_inline如何使热循环膨胀。

Zig
const std = @import("std");
const builtin = @import("builtin");

// Number of iterations to run each benchmark variant
const iterations: usize = 5_000_000;

/// A simple mixing function that demonstrates the performance impact of inlining.
/// Uses bit rotation and arithmetic operations to create a non-trivial workload
/// that the optimizer might handle differently based on call modifiers.
fn mix(value: u32) u32 {
    // Rotate left by 7 bits after XORing with a prime-like constant
    const rotated = std.math.rotl(u32, value ^ 0x9e3779b9, 7);
    // Apply additional mixing with wrapping arithmetic to prevent compile-time evaluation
    return rotated *% 0x85eb_ca6b +% 0xc2b2_ae35;
}

/// Runs the mixing function in a tight loop using the specified call modifier.
/// This allows direct comparison of how different inlining strategies affect performance.
fn run(comptime modifier: std.builtin.CallModifier) u32 {
    var acc: u32 = 0;
    var i: usize = 0;
    while (i < iterations) : (i += 1) {
        // The @call builtin lets us explicitly control inlining behavior at the call site
        acc = @call(modifier, mix, .{acc});
    }
    return acc;
}

pub fn main() !void {
    // Benchmark 1: Let the compiler decide whether to inline (default heuristics)
    var timer = try std.time.Timer.start();
    const auto_result = run(.auto);
    const auto_ns = timer.read();

    // Benchmark 2: Force inlining at every call site
    timer = try std.time.Timer.start();
    const inline_result = run(.always_inline);
    const inline_ns = timer.read();

    // Benchmark 3: Prevent inlining, always emit a function call
    timer = try std.time.Timer.start();
    const never_result = run(.never_inline);
    const never_ns = timer.read();

    // Verify all three strategies produce identical results
    std.debug.assert(auto_result == inline_result);
    std.debug.assert(auto_result == never_result);

    // Display the optimization mode and iteration count for reproducibility
    std.debug.print(
        "optimize-mode={s} iterations={}\n",
        .{
            @tagName(builtin.mode),
            iterations,
        },
    );
    // Report timing results for each call modifier
    std.debug.print("auto call   : {d} ns\n", .{auto_ns});
    std.debug.print("always_inline: {d} ns\n", .{inline_ns});
    std.debug.print("never_inline : {d} ns\n", .{never_ns});
}
运行
Shell
$ zig run 03_call_benchmark.zig -OReleaseFast
输出
Shell
optimize-mode=ReleaseFast iterations=5000000
auto call   : 161394 ns
always_inline: 151745 ns
never_inline : 2116797 ns

-OReleaseSafe下执行相同的运行会使差距更大,因为额外的安全检查放大了每次调用的开销。v0.15.2 当您希望编译器端归因于慢速代码路径时,使用上一章的zig run --time-report38

使用@Vector的可移植向量化

当编译器无法自行推断SIMD使用时,@Vector类型提供了一个可移植的垫片,遵守安全检查和回退标量执行。与@reduce结合使用,您可以表达水平缩减,而无需编写目标特定的内置函数。#reduce

示例:SIMD友好的点积

标量版本和向量化版本产生相同的结果。性能分析确定额外的向量管道在您的目标上是否值得。

Zig
const std = @import("std");

// Number of parallel operations per vector
const lanes = 4;
// Vector type that processes 4 f32 values simultaneously using SIMD
const Vec = @Vector(lanes, f32);

/// Loads 4 consecutive f32 values from a slice into a SIMD vector.
/// The caller must ensure that start + 3 is within bounds.
fn loadVec(slice: []const f32, start: usize) Vec {
    return .{
        slice[start + 0],
        slice[start + 1],
        slice[start + 2],
        slice[start + 3],
    };
}

/// Computes the dot product of two f32 slices using scalar operations.
/// This is the baseline implementation that processes one element at a time.
fn dotScalar(values_a: []const f32, values_b: []const f32) f32 {
    std.debug.assert(values_a.len == values_b.len);
    var sum: f32 = 0.0;
    // Multiply corresponding elements and accumulate the sum
    for (values_a, values_b) |a, b| {
        sum += a * b;
    }
    return sum;
}

/// Computes the dot product using SIMD vectorization for improved performance.
/// Processes 4 elements at a time, then reduces the vector accumulator to a scalar.
/// Requires that the input length is a multiple of the lane count (4).
fn dotVectorized(values_a: []const f32, values_b: []const f32) f32 {
    std.debug.assert(values_a.len == values_b.len);
    std.debug.assert(values_a.len % lanes == 0);

    // Initialize accumulator vector with zeros
    var accum: Vec = @splat(0.0);
    var index: usize = 0;
    // Process 4 elements per iteration using SIMD
    while (index < values_a.len) : (index += lanes) {
        const lhs = loadVec(values_a, index);
        const rhs = loadVec(values_b, index);
        // Perform element-wise multiplication and add to accumulator
        accum += lhs * rhs;
    }

    // Sum all lanes of the accumulator vector into a single scalar value
    return @reduce(.Add, accum);
}

// Verifies that the vectorized implementation produces the same result as the scalar version.
test "vectorized dot product matches scalar" {
    const lhs = [_]f32{ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0 };
    const rhs = [_]f32{ 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0 };
    const scalar = dotScalar(&lhs, &rhs);
    const vector = dotVectorized(&lhs, &rhs);
    // Allow small floating-point error tolerance
    try std.testing.expectApproxEqAbs(scalar, vector, 0.0001);
}
运行
Shell
$ zig test 02_vector_reduction.zig
输出
Shell
All 1 tests passed.

一旦开始混合向量和标量,使用@splat提升常量并避免向量规则禁止的隐式转换。

注意事项与警告

  • 内联递归计入编译时分支配额。仅当测量证明额外的编译时工作值得时,才使用@setEvalBranchQuota提升它。#setevalbranchquota
  • @call(.always_inline, …​)inline关键字之间切换很重要:前者适用于单个站点,而inline修饰被调用方定义和所有未来调用。
  • 除2的幂之外的向量长度可能在某些目标上回退到标量循环。在确信能获胜之前,使用zig build-exe -femit-asm捕获生成的汇编代码。

影响性能的代码生成特性

除了优化模式外,若干代码生成特性会影响运行时性能和调试能力。理解这些标志有助于推理性能权衡:

graph TB subgraph "代码生成特性" Features["特性标志"] Features --> UnwindTables["unwind_tables: bool"] Features --> StackProtector["stack_protector: bool"] Features --> StackCheck["stack_check: bool"] Features --> RedZone["red_zone: ?bool"] Features --> OmitFramePointer["omit_frame_pointer: bool"] Features --> Valgrind["valgrind: bool"] Features --> SingleThreaded["single_threaded: bool"] UnwindTables --> EHFrame["生成 .eh_frame<br/>用于异常处理"] StackProtector --> CanaryCheck["栈金丝雀检查<br/>缓冲区溢出检测"] StackCheck --> ProbeStack["栈探测<br/>防止溢出"] RedZone --> RedZoneSpace["红区优化<br/>(x86_64, AArch64)"] OmitFramePointer --> NoFP["省略帧指针<br/>提升性能"] Valgrind --> ValgrindSupport["Valgrind 客户端请求<br/>用于内存调试"] SingleThreaded --> NoThreading["假设单线程<br/>启用优化"] end

omit_frame_pointer标志与性能工作特别相关:启用时(在ReleaseFast中很典型),编译器释放帧指针寄存器(x86_64上的RBP,ARM上的FP)供通用使用,改善寄存器分配并启用更激进的优化。然而,这会使栈展开变得更困难。调试器和分析器可能产生不完整或缺失的栈跟踪。

red_zone优化(仅限x86_64和AArch64)允许函数在栈指针下方使用128字节而无需调整RSP,减少叶函数的开头/结尾开销。栈保护添加金丝雀检查来检测缓冲区溢出,但会增加运行时成本。这就是ReleaseFast禁用它的原因。栈检查检测函数来探测栈并防止溢出,对深度递归有用但代价高昂。展开表生成.eh_frame部分用于异常处理和调试器栈遍历。调试模式始终包含它们;发布模式可能为了大小而省略它们。

当练习建议使用@call(.never_inline, …​)测量分配器热路径时,这些标志解释了为什么调试模式显示更好的栈跟踪(保留帧指针),代价是执行较慢(额外指令,无寄存器优化)。性能关键代码应该使用ReleaseFast进行基准测试,但使用Debug验证正确性以捕获优化器可能隐藏的问题。

练习

  • 向基准测试程序添加--mode标志,以便您可以在Debug、ReleaseSafe和ReleaseFast运行之间切换,而无需编辑代码。38
  • 使用处理长度不是四的倍数的切片的余数循环扩展点积示例。测量SIMD仍然获胜的交叉点。
  • 在第10章的分配器热路径上试验@call(.never_inline, …​),以确认Debug中改进的栈跟踪是否值得运行时成本。10

替代方案与边缘案例

  • zig run内运行的微基准测试共享编译缓存。在比较计时之前用虚拟运行预热缓存以避免偏差。#入口点和命令结构
  • 自托管的x86后端很快但不完美。如果在探索激进的内联模式时注意到误编译,请回退到-fllvm
  • ReleaseSmall通常完全禁用内联以节省大小。当您同时需要小型二进制文件和调整的热路径时,将热函数隔离并从ReleaseFast构建的共享库中调用它们。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.