tally/engine/src/bitwise.zig

340 lines
14 KiB
Zig

//! Fixed-width integer operations: the bitwise operators, the shifts and the
//! rotations, for both modes.
//!
//! These eight binary operators and two unary ones used to be implemented twice,
//! once in `evaluator.zig` over a 64-bit projection and once in `programmer.zig`
//! over the configured width, and the two disagreed:
//!
//! - `>>` was logical in standard mode and arithmetic in programmer mode, so
//! `-8 >> 1` was 9223372036854775804 in one and -4 in the other.
//! - A shift distance at or beyond the width wrapped modulo 64 in standard mode
//! (`1 << 64` was 1) and clamped to `width - 1` in programmer mode (8-bit
//! `0xFF >>> 20` shifted by 7 and gave 1). Both turned "shift everything out"
//! into "shift a little".
//! - Standard mode ignored the configured width for shifts while honouring it for
//! `~`.
//!
//! FR-2.12 promises that every operator means the same thing in both modes, so
//! there is one implementation, over `Integer` values that carry their own width and
//! signedness. Standard mode is fixed at 64-bit signed; a different width is what
//! programmer mode is for (FR-2.3).
//!
//! Operands are `Integer`, not bare patterns plus a separate type. Taking them apart
//! made it possible to hand an operation a pattern from one width and a type from
//! another, which nothing would have noticed.
const std = @import("std");
const ast = @import("ast.zig");
const BinaryOp = ast.BinaryOp;
const Integer = @import("Integer.zig");
const BitWidth = Integer.BitWidth;
/// The one way a fixed-width operation can fail: a shift or rotate distance that is
/// negative in the operand's type. Everything else about these operators is total.
pub const Error = error{DomainError};
/// The operators this module implements.
///
/// A narrower set than `ast.BinaryOp`: the arithmetic operators are rational in
/// standard mode and wrapping in programmer mode, so they have nothing to share.
pub const Op = enum {
bit_and,
bit_or,
bit_xor,
shift_left,
/// Arithmetic right shift: vacated high bits take the value of the sign bit.
shift_right,
/// Logical right shift: vacated high bits are zero.
shift_right_logical,
rotate_left,
rotate_right,
};
/// The `Op` for a `BinaryOp`, or null for the arithmetic operators.
///
/// Callers use this at comptime from an `inline else` prong, so an operator added
/// to `BinaryOp` that belongs here is a compile error rather than a silent fall
/// through to the wrong tier.
pub fn fromBinaryOp(op: BinaryOp) ?Op {
return switch (op) {
.bit_and => .bit_and,
.bit_or => .bit_or,
.bit_xor => .bit_xor,
.shift_left => .shift_left,
.shift_right => .shift_right,
.shift_right_logical => .shift_right_logical,
.rotate_left => .rotate_left,
.rotate_right => .rotate_right,
.add, .sub, .mul, .div, .mod, .pow => null,
};
}
/// How far a shift moves, once the distance has been checked.
const Distance = union(enum) {
/// Shorter than the width, so some bits survive.
within: u7,
/// At or beyond the width: every original bit leaves the value.
past_width,
};
/// Interpret the right operand of a shift as a distance.
///
/// A negative distance is a domain error rather than a very large one. Standard
/// mode used to reduce it modulo 64, so `8 >> -1` quietly became `8 >> 63`.
fn distance(right: Integer) Error!Distance {
if (right.isNegative()) return Error.DomainError;
const value = right.unsignedValue();
if (value >= right.width.bits()) return .past_width;
return .{ .within = @intCast(value) };
}
/// Interpret the right operand of a rotation as a distance.
///
/// Rotation is cyclic, so a distance beyond the width is reduced rather than
/// saturated: rotating a 64-bit value by 65 is rotating it by 1.
fn rotation(right: Integer) Error!u7 {
if (right.isNegative()) return Error.DomainError;
return @intCast(right.unsignedValue() % right.width.bits());
}
/// Apply a fixed-width operation to two values of the same integer type.
///
/// Both operands must be the same width and signedness; in practice they come from
/// one evaluation with one configuration. The result takes the left operand's type.
pub fn apply(op: Op, left_in: Integer, right_in: Integer) Error!Integer {
std.debug.assert(left_in.sameTypeAs(right_in));
const mask = left_in.width.mask();
const left = left_in.unsignedValue();
const right = right_in.unsignedValue();
const raw: u128 = switch (op) {
.bit_and => left & right,
.bit_or => left | right,
.bit_xor => left ^ right,
// Bits shifted past the end of the width are discarded, not wrapped:
// `0b1000 << 1` is 16. Wrapping is what `rol` and `ror` are for.
.shift_left => switch (try distance(right_in)) {
.past_width => 0,
.within => |amt| (left << amt) & mask,
},
.shift_right_logical => switch (try distance(right_in)) {
.past_width => 0,
.within => |amt| left >> amt,
},
.shift_right => blk: {
const negative = left_in.isNegative();
switch (try distance(right_in)) {
// Shifting a negative value all the way out leaves the sign fill,
// which is every bit set; a non-negative one leaves zero.
.past_width => break :blk if (negative) mask else 0,
.within => |amt| {
if (!negative) break :blk left >> amt;
// Shift the sign-extended value so the sign bit is the fill.
const extended = left_in.signedValue();
break :blk @as(u128, @bitCast(extended >> amt)) & mask;
},
}
},
.rotate_left => blk: {
const amt = try rotation(right_in);
if (amt == 0) break :blk left;
const anti: u7 = @intCast(left_in.width.bits() - amt);
break :blk ((left << amt) | (left >> anti)) & mask;
},
.rotate_right => blk: {
const amt = try rotation(right_in);
if (amt == 0) break :blk left;
const anti: u7 = @intCast(left_in.width.bits() - amt);
break :blk ((left >> amt) | (left << anti)) & mask;
},
};
return left_in.withRaw(raw);
}
/// Bitwise complement within the width.
pub fn not(value: Integer) Integer {
return value.withRaw(~value.unsignedValue());
}
/// Two's complement negation within the width.
pub fn negate(value: Integer) Integer {
return value.withRaw(~value.unsignedValue() +% 1);
}
// -- Tests --
const testing = std.testing;
/// An 8-bit signed value, the width most of these cases are easiest to read in.
fn i8v(raw: u128) Integer {
return .{ .raw = raw, .width = .bits8 };
}
/// An 8-bit unsigned value.
fn u8v(raw: u128) Integer {
return .{ .raw = raw, .width = .bits8, .signedness = .unsigned };
}
/// A 64-bit signed value, which is what standard mode uses.
fn i64v(raw: u128) Integer {
return .{ .raw = raw };
}
test "fromBinaryOp: every fixed-width operator maps, no arithmetic one does" {
// The compiler enforces the total mapping; this pins which side each lands on.
try testing.expectEqual(Op.shift_right, fromBinaryOp(.shift_right).?);
try testing.expectEqual(Op.rotate_right, fromBinaryOp(.rotate_right).?);
for ([_]BinaryOp{ .add, .sub, .mul, .div, .mod, .pow }) |op| {
try testing.expect(fromBinaryOp(op) == null);
}
var fixed_width: usize = 0;
inline for (@typeInfo(BinaryOp).@"enum".fields) |field| {
if (fromBinaryOp(@field(BinaryOp, field.name)) != null) fixed_width += 1;
}
try testing.expectEqual(@as(usize, @typeInfo(Op).@"enum".fields.len), fixed_width);
}
test "the result carries the operands' type" {
const result = try apply(.bit_and, u8v(0xFF), u8v(0x0F));
try testing.expectEqual(Integer.BitWidth.bits8, result.width);
try testing.expectEqual(Integer.Signedness.unsigned, result.signedness);
try testing.expectEqual(@as(u128, 0x0F), result.raw);
}
test "arithmetic right shift fills with the sign bit" {
// -8 in 8 bits is 0b1111_1000; one place right is 0b1111_1100, which is -4.
const result = try apply(.shift_right, i8v(0b1111_1000), i8v(1));
try testing.expectEqual(@as(u128, 0b1111_1100), result.raw);
try testing.expectEqual(@as(i128, -4), result.signedValue());
}
test "logical right shift fills with zeros" {
// The same bits, shifted the other way: 0b0111_1100 is 124.
const result = try apply(.shift_right_logical, i8v(0b1111_1000), i8v(1));
try testing.expectEqual(@as(u128, 124), result.raw);
}
test "the two right shifts agree on non-negative values" {
var value: u128 = 0;
while (value < 0x80) : (value += 1) {
var amt: u128 = 0;
while (amt < 8) : (amt += 1) {
const arithmetic = try apply(.shift_right, i8v(value), i8v(amt));
const logical = try apply(.shift_right_logical, i8v(value), i8v(amt));
try testing.expectEqual(arithmetic.raw, logical.raw);
}
}
}
test "an unsigned value has no sign to extend" {
// 0xFF is 255 here, not -1, so the arithmetic shift is a zero fill too. The old
// programmer-mode implementation looked at the top bit regardless of the
// configured signedness and gave 0xFF.
const arithmetic = try apply(.shift_right, u8v(0xFF), u8v(1));
const logical = try apply(.shift_right_logical, u8v(0xFF), u8v(1));
try testing.expectEqual(@as(u128, 0x7F), arithmetic.raw);
try testing.expectEqual(@as(u128, 0x7F), logical.raw);
}
test "shifts run to completion instead of wrapping or clamping the distance" {
// Standard mode reduced the distance modulo the width, so `1 << 64` was 1;
// programmer mode clamped it to width - 1, so 8-bit `0xFF >>> 20` was 1.
try testing.expectEqual(@as(u128, 0), (try apply(.shift_left, i64v(1), i64v(64))).raw);
try testing.expectEqual(@as(u128, 0), (try apply(.shift_left, i64v(1), i64v(1000))).raw);
try testing.expectEqual(@as(u128, 0), (try apply(.shift_right_logical, i8v(0xFF), i8v(20))).raw);
try testing.expectEqual(@as(u128, 0), (try apply(.shift_left, i8v(0xFF), i8v(8))).raw);
// A negative value shifted all the way out is all sign bits, not zero.
try testing.expectEqual(@as(u128, 0xFF), (try apply(.shift_right, i8v(0b1111_1000), i8v(8))).raw);
try testing.expectEqual(@as(u128, 0xFF), (try apply(.shift_right, i8v(0b1111_1000), i8v(100))).raw);
// A non-negative one is zero.
try testing.expectEqual(@as(u128, 0), (try apply(.shift_right, i8v(0b0100_0000), i8v(8))).raw);
}
test "shifting by one less than the width still keeps a bit" {
// The boundary the clamping rule used to hide.
try testing.expectEqual(@as(u128, 0b1000_0000), (try apply(.shift_left, i8v(1), i8v(7))).raw);
try testing.expectEqual(@as(u128, 1), (try apply(.shift_right_logical, i8v(0b1000_0000), i8v(7))).raw);
try testing.expectEqual(@as(u128, 0xFF), (try apply(.shift_right, i8v(0b1000_0000), i8v(7))).raw);
}
test "a negative shift distance is a domain error, not a huge one" {
const neg_one = i8v(0xFF); // -1 in 8-bit signed
try testing.expectError(Error.DomainError, apply(.shift_left, i8v(1), neg_one));
try testing.expectError(Error.DomainError, apply(.shift_right, i8v(1), neg_one));
try testing.expectError(Error.DomainError, apply(.shift_right_logical, i8v(1), neg_one));
try testing.expectError(Error.DomainError, apply(.rotate_left, i8v(1), neg_one));
try testing.expectError(Error.DomainError, apply(.rotate_right, i8v(1), neg_one));
// The same pattern in an unsigned value is 255, a distance past the width.
try testing.expectEqual(@as(u128, 0), (try apply(.shift_left, u8v(1), u8v(0xFF))).raw);
}
test "rotation is cyclic and reduces the distance" {
try testing.expectEqual(
@as(u128, 0b0000_0011),
(try apply(.rotate_left, i8v(0b1000_0001), i8v(1))).raw,
);
try testing.expectEqual(
@as(u128, 0b1100_0000),
(try apply(.rotate_right, i8v(0b1000_0001), i8v(1))).raw,
);
// Rotating by the width is the identity, and by width + 1 is by 1.
try testing.expectEqual(
@as(u128, 0b1000_0001),
(try apply(.rotate_left, i8v(0b1000_0001), i8v(8))).raw,
);
try testing.expectEqual(
@as(u128, 0b0000_0011),
(try apply(.rotate_left, i8v(0b1000_0001), i8v(9))).raw,
);
}
test "rotate left and rotate right are inverses at every distance and width" {
for (std.enums.values(BitWidth)) |bw| {
const value: Integer = .{
.raw = 0x1234_5678_9ABC_DEF0 & bw.mask(),
.width = bw,
.signedness = .unsigned,
};
var amt: u128 = 0;
while (amt < value.width.bits()) : (amt += 1) {
const there = try apply(.rotate_left, value, value.withRaw(amt));
const back = try apply(.rotate_right, there, value.withRaw(amt));
try testing.expectEqual(value.raw, back.raw);
}
}
}
test "results stay inside the width, at every width and operator" {
for (std.enums.values(BitWidth)) |bw| {
const all_ones: Integer = .{ .raw = bw.mask(), .width = bw };
inline for (@typeInfo(Op).@"enum".fields) |field| {
const op = @field(Op, field.name);
// 1 is a safe distance for the shifts and a legal operand for the rest.
const result = try apply(op, all_ones, all_ones.withRaw(1));
try testing.expectEqual(result.raw, result.unsignedValue());
try testing.expect(result.sameTypeAs(all_ones));
}
}
}
test "not and negate stay inside the width and keep the type" {
try testing.expectEqual(@as(u128, 0xFF), not(i8v(0)).raw);
try testing.expectEqual(@as(u128, 0), not(i8v(0xFF)).raw);
try testing.expectEqual(@as(u128, 0xFF), negate(i8v(1)).raw);
try testing.expectEqual(@as(u128, 1), negate(i8v(0xFF)).raw);
// Negating the most negative value gives itself back, as two's complement does.
try testing.expectEqual(@as(u128, 0x80), negate(i8v(0x80)).raw);
try testing.expect(not(u8v(0)).sameTypeAs(u8v(0)));
}
test "the default value type, which standard mode uses, is 64-bit signed" {
const minus_eight = i64v(@bitCast(@as(i128, -8) & @as(i128, @bitCast(Integer.BitWidth.bits64.mask()))));
try testing.expectEqual(Integer.BitWidth.bits64, minus_eight.width);
try testing.expectEqual(Integer.Signedness.signed, minus_eight.signedness);
// -8 >> 1 is -4 there, which is the case that used to differ between modes.
const shifted = try apply(.shift_right, minus_eight, minus_eight.withRaw(1));
try testing.expectEqual(@as(i128, -4), shifted.signedValue());
}