Files
ghostty/src/quirks_memset.zig
Riccardo Mazzarini 20c3eae04d memset should match the C ABI
The custom memset accepted its fill value as u8 even though C callers
pass int. Accept c_int and explicitly truncate it to the low byte, which
is what other implementations of this do.
2026-07-26 14:33:34 -07:00

339 lines
13 KiB
Zig

//! Provides a fast `memset` symbol that overrides the slow scalar
//! implementation provided by Zig 0.16's compiler_rt.
//!
//! Consider deleting when upstream ships an optimized compiler_rt
//! memset (tracked by ziglang/zig#32091, maybe fixed at
//! ziglang/zig#35754). To verify it is safe to delete, check that
//! the disassembly of `memset` in a ReleaseFast binary is vectorized
//! (or at least not a byte-at-a-time loop).
//!
//! ## Background
//!
//! Zig 0.16.0 globally disabled LLVM loop auto-vectorization to work
//! around an LLVM 21 miscompilation (llvm/llvm-project#186922, see
//! the 0.16.0 release notes). compiler_rt's memset is a naive
//! byte-at-a-time loop that relied entirely on auto-vectorization,
//! so it now compiles to a scalar loop roughly 24x slower than what
//! Zig 0.15 produced.
//!
//! In benchmarks this made `ghostty-bench +terminal-stream` 2.8x slower on
//! ASCII input (memset was 63% of all executed instructions!).
//!
//! Because compiler_rt's symbol is weak, exporting a strong `memset`
//! from our own code transparently overrides it everywhere. The
//! implementation below is manually vectorized with `@Vector`, which
//! does not depend on the disabled loop vectorizer.
//!
//! Other mem functions deliberately not overridden:
//!
//! - memcpy/memmove: compiler_rt's implementations are manually
//! vectorized upstream ("memcpyFast") and remain fast.
//! - memcmp/bcmp/strlen: also scalar in 0.16, but they never showed
//! up in our profiles.
//!
//! This file has no effect unless it is referenced from an artifact
//! root (e.g. `comptime { _ = @import("quirks_memset.zig"); }`).
//! It must NOT be imported from shared code, otherwise downstream
//! consumers of our Zig modules would get this export injected into
//! their binaries.
//!
//! ## References
//!
//! I referenced the musl asm + c memset implementation (MIT licensed).
//! The primary thing I took away from that is the dc zva trick for
//! aarch64. The remainder is fairly obvious memset work.
const std = @import("std");
const builtin = @import("builtin");
comptime {
// Strong linkage when we control the final link (executables,
// shared libraries), weak otherwise.
const linkage: std.builtin.GlobalLinkage = switch (builtin.output_mode) {
.Exe => .strong,
.Lib => switch (builtin.link_mode) {
.dynamic => .strong,
.static => .weak,
},
.Obj => .weak,
};
// Whether the override is emitted:
//
// 1. On targets without SIMD we disable, since compiler_rt's
// scalar operations are going to be just as good.
// 2. The C object format target uses strong linkage which
// conflits with ours and errors.
// 3. Weak COFF builds fatally error because MSVC's linker
// errors when two identical linked symbols exist. MSVC has
// CRT which links so we don't need this there anyways.
const enabled =
std.simd.suggestVectorLength(u8) != null and
builtin.object_format != .c and
!(linkage == .weak and builtin.object_format == .coff);
if (enabled) @export(&memset, .{
.name = "memset",
.linkage = linkage,
// Hidden so that shared library builds (libghostty,
// libghostty-vt) resolve this internally without exporting
// it to their host applications.
.visibility = .hidden,
});
}
/// Bytes stored per loop iteration. Twice the native vector size so
/// each iteration issues two vector stores (e.g. `stp q0, q0` on
/// aarch64). Capped at 128 both because the small path below relies
/// on len < 128 once the loop is skipped, and because wider single
/// iterations stop paying off anyway (e.g. on scalable-vector
/// targets that suggest very large lengths).
const vec_bytes = @min(128, 2 * (std.simd.suggestVectorLength(u8) orelse 8));
/// Whether the `dc zva` fast path for large zero fills is available.
/// `dc zva` zeroes a whole cacheline per instruction without moving data
/// through the store pipeline. Based on musl.
const zva_enabled = builtin.cpu.arch == .aarch64 and
builtin.os.tag != .freestanding;
/// Only use `dc zva` at or above this many bytes. Below this our
/// plain vector loop measures faster. Empirically mesaured.
const zva_threshold = 16384;
/// Matches the C memset ABI.
fn memset(dest: ?[*]u8, c: c_int, len: usize) callconv(.c) ?[*]u8 {
@setRuntimeSafety(false);
if (len == 0) return dest;
const d = dest.?;
// Only the low byte of `c` is used. The C ABI allows a "int"
// as the c parameter but this behavior of just grabbing the
// low byte seems consistent across implementations.
const byte: u8 = @truncate(@as(c_uint, @bitCast(c)));
// Large path: full-width vector stores.
if (len >= vec_bytes) {
// Very large zero fills: zero whole cachelines with `dc zva`
// instead. Only implemented for the (universal in practice)
// 64-byte block size; anything else falls through to the
// vector loop.
if (comptime zva_enabled) {
if (len >= zva_threshold and byte == 0) zva: {
if (zvaSize() != 64) break :zva;
const splat64: @Vector(64, u8) = @splat(0);
// Head: one unaligned store covering every byte up
// to the first 64-aligned address (and usually a bit
// beyond it; the overlap is fine).
const addr = @intFromPtr(d);
const aligned = std.mem.alignForward(usize, addr, 64);
d[0..64].* = splat64;
// Zero whole aligned cachelines while at least one
// full line remains in range. `dc zva` requires the
// WHOLE line to be inside the buffer: it always
// zeroes all 64 bytes.
var p: [*]u8 = @ptrFromInt(aligned);
const end_addr = addr + len;
while (@intFromPtr(p) + 64 <= end_addr) : (p += 64) {
asm volatile ("dc zva, %[ptr]"
:
: [ptr] "r" (p),
: .{ .memory = true });
}
// Tail: overlapping unaligned store anchored to the
// end of the buffer, covering whatever the line loop
// could not. len >= zva_threshold >= 64 so this
// cannot underflow.
d[len - 64 ..][0..64].* = splat64;
return dest;
}
}
const splat: @Vector(vec_bytes, u8) = @splat(byte);
// Fill [0, N) where N is len rounded down to a multiple of
// vec_bytes.
var i: usize = 0;
while (i + vec_bytes <= len) : (i += vec_bytes) {
d[i..][0..vec_bytes].* = splat;
// This empty asm statement prevents LLVM's
// LoopIdiomRecognize pass from replacing this loop with
// a call to memset, which would be infinite recursion
// since we ARE memset. compiler_rt is protected from
// this by being built with -fno-builtin; Ghostty is not.
asm volatile ("" ::: .{ .memory = true });
}
// Fill the remaining [N, len) tail, if any, with one more
// full-width store anchored to the END of the buffer. It
// overlaps up to vec_bytes-1 bytes that the loop already
// wrote, which is fine (same value), and cannot underflow
// because len >= vec_bytes in this path.
if (i != len) d[len - vec_bytes ..][0..vec_bytes].* = splat;
return dest;
}
// Small path (len < vec_bytes): pairs of narrower stores, one
// anchored to the start of the buffer and one to the end.
//
// The invariant for each branch below: a store of width w at
// [0..w] plus a store at [len-w..len] covers every byte exactly
// when w <= len <= 2*w. Smaller lens are handled by a later
// branch; larger lens by an earlier one.
comptime std.debug.assert(vec_bytes <= 128);
if (comptime vec_bytes > 64) {
if (len >= 64) {
// 64 <= len < vec_bytes <= 128 (asserted above): one
// pair of 64-byte stores covers len <= 128. Only emitted
// when vec_bytes > 64 (e.g. AVX-512), otherwise the
// vector loop above already handled these lengths.
const splat64: @Vector(64, u8) = @splat(byte);
d[0..64].* = splat64;
d[len - 64 ..][0..64].* = splat64;
return dest;
}
}
if (len >= 16) {
// 16 <= len < @min(vec_bytes, 64), so at most 63. One pair
// of 16-byte stores covers len <= 32. For 32 < len < 64, a
// second pair extends the covered prefix to [0..32] and the
// covered suffix to [len-32..len], which meet or overlap in
// the middle.
const splat16: @Vector(16, u8) = @splat(byte);
d[0..16].* = splat16;
d[len - 16 ..][0..16].* = splat16;
if (len > 32) {
d[16..32].* = splat16;
d[len - 32 ..][0..16].* = splat16;
}
return dest;
}
if (len >= 8) {
// 8 <= len <= 15: one 8-byte pair (covers len <= 16).
const splat8: @Vector(8, u8) = @splat(byte);
d[0..8].* = splat8;
d[len - 8 ..][0..8].* = splat8;
return dest;
}
if (len >= 4) {
// 4 <= len <= 7: one 4-byte pair (covers len <= 8).
const splat4: @Vector(4, u8) = @splat(byte);
d[0..4].* = splat4;
d[len - 4 ..][0..4].* = splat4;
return dest;
}
// 1 <= len <= 3: too short for the pair trick; write bytes one
// at a time. The asm statement again keeps LoopIdiomRecognize
// from turning this into a recursive memset call; LLVM will do
// that even for a loop this short because the trip count is not
// statically known.
var i: usize = 0;
while (i < len) : (i += 1) {
d[i] = byte;
asm volatile ("" ::: .{ .memory = true });
}
return dest;
}
/// Cached `dc zva` block size: 0 = not yet queried, 1 = unavailable
/// or prohibited, otherwise the block size in bytes. Racing threads
/// store the same value so the memory ordering is irrelevant.
var zva_size: std.atomic.Value(usize) = .init(0);
fn zvaSize() usize {
const cached = zva_size.load(.monotonic);
if (cached != 0) return cached;
const dczid = asm ("mrs %[out], dczid_el0"
: [out] "=r" (-> u64),
);
// Bit 4 (DZP) prohibits DC ZVA; bits 0-3 are log2 of the block
// size in 4-byte words.
const size: usize = if (dczid & 0x10 != 0)
1
else
@as(usize, 4) << @intCast(dczid & 0xF);
zva_size.store(size, .monotonic);
return size;
}
test memset {
const testing = std.testing;
// A buffer larger than anything the small/vector paths special
// case, with room for offset (alignment) variations. Guard bytes
// around the fill region must remain untouched.
var buf: [4 * vec_bytes + 33]u8 = undefined;
for (0..vec_bytes + 1) |offset| {
for (0..buf.len - offset) |len| {
for ([_]u8{ 0x00, 0x5C, 0xFF }) |c| {
@memset(&buf, 0xAA);
const region = buf[offset..][0..len];
const ret = memset(region.ptr, c, len);
try testing.expectEqual(region.ptr, ret.?);
for (buf[0..offset]) |b| try testing.expectEqual(0xAA, b);
for (region) |b| try testing.expectEqual(c, b);
for (buf[offset + len ..]) |b| try testing.expectEqual(0xAA, b);
}
}
}
// Zero length must not touch memory and must tolerate null.
try testing.expectEqual(null, memset(null, 0x11, 0));
var one: [1]u8 = .{0xAA};
_ = memset(&one, 0x11, 0);
try testing.expectEqual(0xAA, one[0]);
}
test "memset truncates C int fill value" {
const testing = std.testing;
var buf: [4]u8 = undefined;
_ = memset(&buf, -1, buf.len);
for (buf) |b| try testing.expectEqual(0xFF, b);
_ = memset(&buf, 0x1234, buf.len);
for (buf) |b| try testing.expectEqual(0x34, b);
}
test "memset large zero fills (dc zva path)" {
const testing = std.testing;
const alloc = testing.allocator;
// Sizes at and around the ZVA threshold, with a guard byte on
// each side and every offset within a cacheline so the head and
// tail alignment handling is fully exercised.
const buf = try alloc.alloc(u8, 2 * zva_threshold + 66);
defer alloc.free(buf);
for ([_]usize{
zva_threshold - 1,
zva_threshold,
zva_threshold + 63,
2 * zva_threshold,
}) |len| {
for (0..65) |offset| {
@memset(buf, 0xAA);
const region = buf[1 + offset ..][0..len];
_ = memset(region.ptr, 0x00, len);
for (buf[0 .. 1 + offset]) |b| try testing.expectEqual(0xAA, b);
for (region) |b| try testing.expectEqual(0x00, b);
for (buf[1 + offset + len ..]) |b| try testing.expectEqual(0xAA, b);
}
}
// Nonzero fills of ZVA-eligible sizes must not take the ZVA path
// (it can only write zeroes).
@memset(buf, 0xAA);
_ = memset(buf.ptr + 1, 0x5C, zva_threshold);
try testing.expectEqual(0xAA, buf[0]);
for (buf[1 .. 1 + zva_threshold]) |b| try testing.expectEqual(0x5C, b);
try testing.expectEqual(0xAA, buf[1 + zva_threshold]);
}