Chapter 39Performance And Inlining

性能与内联

概览

我们的 CLI 调研为有纪律的试验奠定了基础(38)。现在关注 Zig 如何将这些命令行开关转换为机器层行为。语义内联、调用修饰符与显式 SIMD 都为你提供塑造热点路径的杠杆——前提是谨慎测量并尊重编译器默认值。#inline fn

下一章将在此测量循环之上叠加分析与加固工作流,使其规范化。40

学习目标

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

#call, Timer.zig, #vectors

语义内联 vs 优化器启发

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
// 此文件演示Zig的内联语义和编译时执行特性。
// 它展示了`inline`关键字和`@call`内置函数如何控制
// 函数在编译时与运行时的评估时机和方式。

const std = @import("std");

// 使用递归计算第n个斐波那契数。
// `inline`关键字强制此函数在所有调用点被内联,
// 而`comptime n`参数确保值可以在编译时计算。
// 这种组合允许结果作为编译时常量可用。
inline fn fib(comptime n: usize) usize {
    return if (n <= 1) n else fib(n - 1) + fib(n - 2);
}

// 使用递归计算n的阶乘。
// 与`fib`不同,此函数未标记为`inline`,因此编译器
// 根据优化启发式算法决定是否内联它。
// 它可以在编译时或运行时调用。
fn factorial(n: usize) usize {
    return if (n <= 1) 1 else n * factorial(n - 1);
}

// 演示具有comptime参数的inline函数
// 将编译时执行传播到其调用点。
// 整个计算在comptime块内的编译时发生。
test "inline fibonacci propagates comptime" {
    comptime {
        const value = fib(10);
        try std.testing.expectEqual(@as(usize, 55), value);
    }
}

// 演示带有`.compile_time`修饰符的`@call`内置函数。
// 这强制函数调用在编译时评估,
// 尽管`factorial`未标记为`inline`且接受非comptime参数。
test "@call compile_time modifier" {
    const result = @call(.compile_time, factorial, .{5});
    try std.testing.expectEqual(@as(usize, 120), result);
}

// 验证非内联函数仍可在运行时调用。
// 输入是运行时值,因此计算在执行期间发生。
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");

// 运行每个基准变体的迭代次数
const iterations: usize = 5_000_000;

// 演示内联性能影响的简单混合函数。
// 使用位旋转和算术运算创建非平凡的工作负载,
// 优化器可能根据调用修饰符以不同方式处理。
fn mix(value: u32) u32 {
    // 与类素数常量异或后左旋7位
    const rotated = std.math.rotl(u32, value ^ 0x9e3779b9, 7);
    // 使用环绕算术应用额外混合以防止编译时求值
    return rotated *% 0x85eb_ca6b +% 0xc2b2_ae35;
}

// 使用指定的调用修饰符在紧循环中运行混合函数。
// 这允许直接比较不同内联策略如何影响性能。
fn run(comptime modifier: std.builtin.CallModifier) u32 {
    var acc: u32 = 0;
    var i: usize = 0;
    while (i < iterations) : (i += 1) {
        // @call内置函数让我们在调用点显式控制内联行为
        acc = @call(modifier, mix, .{acc});
    }
    return acc;
}

pub fn main() !void {
    // 基准测试1:让编译器决定是否内联(默认启发式)
    var timer = try std.time.Timer.start();
    const auto_result = run(.auto);
    const auto_ns = timer.read();

    // 基准测试2:在每个调用点强制内联
    timer = try std.time.Timer.start();
    const inline_result = run(.always_inline);
    const inline_ns = timer.read();

    // 基准测试3:防止内联,始终发出函数调用
    timer = try std.time.Timer.start();
    const never_result = run(.never_inline);
    const never_ns = timer.read();

    // 验证所有三种策略产生相同的结果
    std.debug.assert(auto_result == inline_result);
    std.debug.assert(auto_result == never_result);

    // 显示优化模式和迭代次数以确保可重现性
    std.debug.print(
        "optimize-mode={s} iterations={}\n",
        .{
            @tagName(builtin.mode),
            iterations,
        },
    );
    // 报告每个调用修饰符的计时结果
    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

Performing the same run under -OReleaseSafe makes the gap larger because additional safety checks amplify the per-call overhead. v0.15.2 Use zig run --time-report from the previous chapter when you want compiler-side attribution for slow code paths. 38

使用 @Vector 的可移植向量化

当编译器无法自行推断 SIMD 使用时,@Vector类型提供可移植的垫片,既遵循安全检查,也支持回退标量执行。配合@reduce,你可表达水平归约,而无需编写目标特定的内建。#reduce

示例:适合 SIMD 的点积

标量与向量化版本产出一致结果;究竟额外的向量管线是否值得,需由性能分析在你的目标平台上裁定。

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

// 每个向量的并行操作数
const lanes = 4;
// 处理4个f32值同时使用SIMD的向量类型
const Vec = @Vector(lanes, f32);

// 从切片加载4个连续的f32值到SIMD向量中。
// 调用者必须确保start + 3在边界内。
fn loadVec(slice: []const f32, start: usize) Vec {
    return .{
        slice[start + 0],
        slice[start + 1],
        slice[start + 2],
        slice[start + 3],
    };
}

// 使用标量操作计算两个f32切片的点积。
// 这是基线实现,一次处理一个元素。
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;
    // 乘以对应元素并累积求和
    for (values_a, values_b) |a, b| {
        sum += a * b;
    }
    return sum;
}

// 使用SIMD向量化计算点积以提高性能。
// 一次处理4个元素,然后将向量累加器归约为标量。
// 要求输入长度是通道数(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);

    // 用零初始化累加器向量
    var accum: Vec = @splat(0.0);
    var index: usize = 0;
    // 每次迭代使用SIMD处理4个元素
    while (index < values_a.len) : (index += lanes) {
        const lhs = loadVec(values_a, index);
        const rhs = loadVec(values_b, index);
        // 执行按元素乘法并添加到累加器
        accum += lhs * rhs;
    }

    // 将累加器向量的所有通道求和为单个标量值
    return @reduce(.Add, accum);
}

// 验证向量化实现产生与标量版本相同的结果。
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);
    // 允许小的浮点误差容忍度
    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)允许函数在不调整 RSP 的情况下使用栈指针下方 128 字节,减少叶子函数的序言/尾声开销。栈保护通过金丝雀检测缓冲区溢出,但带来运行期开销,因此在 ReleaseFast 中禁用。栈检查为函数插桩以探测栈并防止溢出,适用于深度递归但成本较高。展开表生成.eh_frame段用于异常处理与调试器栈遍历;调试模式总是包含它们,发布模式可能因体积而省略。

当练习建议使用@call(.never_inline, …​)衡量分配器热点路径时,这些标志解释了为何 Debug 模式的栈踪更好(保留帧指针),但执行更慢(更多指令、无寄存器优化)。性能关键代码应在 ReleaseFast 下做基准,在 Debug 下校验正确性,以捕获可能被优化器掩盖的问题。

练习

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

替代方案与边界情况:

  • zig run 内部运行的微基准测试共享编译缓存。在比较计时之前,使用虚拟运行预热缓存以避免偏差。#entry points and command structure
  • 自托管 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.