diff --git a/CHANGES.md b/CHANGES.md index 4233da5..734047f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,16 @@ # p99.Zig CHANGES +## 0.0.3 - 26th July 2026 + +Added `binary-scaling` optimisation + +* Implemented optional $2^{32}$ fixed-point binary scaling float-avoidance optimizations from `p99.Rust` as a comptime build option (`-Dbinary-scaling`); +* Added `binary-scaling` option to `build.zig` and passed it to the root module as `build_options`; +* Updated integer-based percentile methods (`valueAtP90`, `valueAtP95`, `valueAtP99`, etc.) to use pre-encoded $2^{32}$ fixed-point multipliers when `binary_scaling` is enabled; +* Bumped package version to `0.0.3`; + + ## 0.0.2 - 26th July 2026 GitHub Actions CI diff --git a/NEWS.md b/NEWS.md index ddfe7bb..e0e7e28 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,7 @@ | Date | News Item | | --------------------- | ----------------------------------------- | +| 26th July 2026 | Release 0.0.3: Optional 2^32 binary scaling optimizations added as a comptime build option. | | 26th July 2026 | Release 0.0.2: GitHub Actions CI support added for Linux, macOS, and Windows. | | 26th July 2026 | Release 0.0.1: Core Histogram implementation with unit tests and benchmarks is complete. | diff --git a/README.md b/README.md index 55433e2..5ae33fd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ Very low-cost measuring of performance percentiles, for Zig - [Introduction](#introduction) - [How It Works](#how-it-works) - [Installation](#installation) +- [Build Options](#build-options) - [Minimal Example](#minimal-example) +- [Benchmarks](#benchmarks) - [Project Information](#project-information) - [Where to get help](#where-to-get-help) - [Contribution guidelines](#contribution-guidelines) @@ -81,6 +83,23 @@ exe.root_module.addImport("p99", p99_dep.module("p99")); ``` +## Build Options + +`p99.Zig` supports the following compile-time build options: + +* **`binary-scaling`** *(bool, default: `false`)*: Replaces integer division in the integer-based percentile methods (`valueAtP90`, `valueAtP95`, `valueAtP99`, etc.) with $2^{32}$ fixed-point binary scaling. Each percentile multiplier (e.g., `0.90` for p90) is pre-encoded as a `u32` constant and the target rank is computed via a single multiplication and a 32-bit right-shift, avoiding the cost of integer division entirely. This yields a significant speedup for percentile queries with a negligible loss of accuracy (the scaled multiplier differs from the true value by less than $10^{-9}$). The generic `valueAtPercentile(f64)` method is unaffected by this feature. + +To enable this option in your project, pass it when fetching the dependency in your `build.zig`: + +```zig +const p99_dep = b.dependency("p99", .{ + .target = target, + .optimize = optimize, + .@"binary-scaling" = true, +}); +``` + + ## Minimal Example Here is a minimal example demonstrating how to use `p99.Zig`: @@ -115,6 +134,23 @@ pub fn main(init: std.process.Init) !void { ``` +## Benchmarks + +A benchmark suite is included in `benches/benchmark_histogram.zig` to measure performance. + +To run the benchmarks in **ReleaseFast** mode (without binary scaling): + +```bash +zig build bench -Doptimize=ReleaseFast +``` + +To run the benchmarks with the **binary-scaling** optimization enabled: + +```bash +zig build bench -Dbinary-scaling=true -Doptimize=ReleaseFast +``` + + ## Project Information diff --git a/TODO.md b/TODO.md index 1379997..3993515 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ ## Performance improvements -- [ ] Implement $2^{32}$ fixed-point binary scaling float-avoidance optimizations from `p99.Rust` as a comptime build option. +- [x] Implement $2^{32}$ fixed-point binary scaling float-avoidance optimizations from `p99.Rust` as a comptime build option. ## Packaging improvements diff --git a/benches/benchmark_histogram.zig b/benches/benchmark_histogram.zig index 458a158..06859c9 100644 --- a/benches/benchmark_histogram.zig +++ b/benches/benchmark_histogram.zig @@ -11,49 +11,174 @@ pub fn main(init: std.process.Init) !void { // 1. Benchmark: Pushing sequential events { - var h = p99.Histogram{}; const count = 100_000; + const trials = 50; + var total_ns: u64 = 0; - const start = std.Io.Clock.awake.now(io); - var i: u64 = 1; - while (i <= count) : (i += 1) { - _ = h.pushEventTimeNs(i); + // Warmup phase (to heat caches and scale CPU frequency) + { + var warmup_h = p99.Histogram{}; + var i: u64 = 1; + while (i <= 10_000) : (i += 1) { + var val = i; + std.mem.doNotOptimizeAway(&val); + _ = warmup_h.pushEventTimeNs(val); + } + std.mem.doNotOptimizeAway(&warmup_h); } - const elapsed = start.untilNow(io, .awake); - const ns = elapsed.nanoseconds; - const avg_ns = @as(f64, @floatFromInt(ns)) / @as(f64, @floatFromInt(count)); + + // Multiple trials for statistical stability + var t: usize = 0; + while (t < trials) : (t += 1) { + var h = p99.Histogram{}; + const start = std.Io.Clock.awake.now(io); + var i: u64 = 1; + while (i <= count) : (i += 1) { + var val = i; + std.mem.doNotOptimizeAway(&val); // Force LLVM to treat input as dynamic runtime value! + _ = h.pushEventTimeNs(val); + } + const elapsed = start.untilNow(io, .awake); + std.mem.doNotOptimizeAway(&h); // Prevent LLVM from deleting the loop! + total_ns += @as(u64, @intCast(elapsed.nanoseconds)); + } + + const avg_ns = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(count * trials)); try stdout.print("Benchmark: Push Sequential Events\n", .{}); - try stdout.print(" Total events: {d}\n", .{count}); - try stdout.print(" Total time: {d} ns\n", .{ns}); + try stdout.print(" Total events: {d} (across {d} trials)\n", .{ count * trials, trials }); try stdout.print(" Average/push: {d:.2} ns\n\n", .{avg_ns}); } - // 2. Benchmark: Pushing random events (using a simple LCG PRNG for speed) + // 2. Benchmark: Pushing random events (1ns to 10s wide-range) { - var h = p99.Histogram{}; const count = 100_000; + const trials = 50; + var total_ns: u64 = 0; var seed: u64 = 12345; - const start = std.Io.Clock.awake.now(io); + // Warmup + { + var warmup_h = p99.Histogram{}; + var i: u64 = 1; + while (i <= 10_000) : (i += 1) { + seed = seed *% 6364136223846793005 +% 1442695040888963407; + var val = (seed % 10_000_000_000) + 1; + std.mem.doNotOptimizeAway(&val); + _ = warmup_h.pushEventTimeNs(val); + } + std.mem.doNotOptimizeAway(&warmup_h); + } + + var t: usize = 0; + while (t < trials) : (t += 1) { + var h = p99.Histogram{}; + const start = std.Io.Clock.awake.now(io); + var i: u64 = 1; + while (i <= count) : (i += 1) { + seed = seed *% 6364136223846793005 +% 1442695040888963407; + var val = (seed % 10_000_000_000) + 1; + std.mem.doNotOptimizeAway(&val); // Force LLVM to treat input as dynamic runtime value! + _ = h.pushEventTimeNs(val); + } + const elapsed = start.untilNow(io, .awake); + std.mem.doNotOptimizeAway(&h); // Prevent loop deletion + total_ns += @as(u64, @intCast(elapsed.nanoseconds)); + } + + const avg_ns = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(count * trials)); + + try stdout.print("Benchmark: Push Random Events (1ns to 10s wide-range)\n", .{}); + try stdout.print(" Total events: {d} (across {d} trials)\n", .{ count * trials, trials }); + try stdout.print(" Average/push: {d:.2} ns\n\n", .{avg_ns}); + } + + // 3. Benchmark: Percentile Queries (valueAtP99 on 10s wide-range) + { + var h = p99.Histogram{}; + const count = 100_000; + var seed: u64 = 12345; var i: u64 = 1; while (i <= count) : (i += 1) { - // Simple LCG PRNG seed = seed *% 6364136223846793005 +% 1442695040888963407; - const val = (seed % 1_000_000) + 1; + const val = (seed % 10_000_000_000) + 1; _ = h.pushEventTimeNs(val); } - const elapsed = start.untilNow(io, .awake); - const ns = elapsed.nanoseconds; - const avg_ns = @as(f64, @floatFromInt(ns)) / @as(f64, @floatFromInt(count)); + std.mem.doNotOptimizeAway(&h); - try stdout.print("Benchmark: Push Random Events (1ns to 1ms)\n", .{}); - try stdout.print(" Total events: {d}\n", .{count}); - try stdout.print(" Total time: {d} ns\n", .{ns}); - try stdout.print(" Average/push: {d:.2} ns\n\n", .{avg_ns}); + const query_count = 10_000; + const trials = 50; + var total_ns: u64 = 0; + + // Warmup + { + var q: u64 = 0; + while (q < 1_000) : (q += 1) { + var val = h.valueAtP99(); + std.mem.doNotOptimizeAway(&val); + } + } + + var t: usize = 0; + while (t < trials) : (t += 1) { + const start = std.Io.Clock.awake.now(io); + var q: u64 = 0; + while (q < query_count) : (q += 1) { + std.mem.doNotOptimizeAway(&h); // Force LLVM to assume 'h' might be modified, preventing loop hoisting/caching! + var val = h.valueAtP99(); + std.mem.doNotOptimizeAway(&val); // Prevent query loop deletion + } + const elapsed = start.untilNow(io, .awake); + total_ns += @as(u64, @intCast(elapsed.nanoseconds)); + } + + const avg_ns = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(query_count * trials)); + + try stdout.print("Benchmark: Percentile Queries (valueAtP99 on 10s wide-range)\n", .{}); + try stdout.print(" Total queries: {d} (across {d} trials)\n", .{ query_count * trials, trials }); + try stdout.print(" Average/query: {d:.2} ns\n\n", .{avg_ns}); } - // 3. Benchmark: Percentile Queries + // 4. Benchmark: Clear + { + const trials = 50; + const loop_count = 10_000; + var total_ns: u64 = 0; + + // Warmup + { + var warmup_h = p99.Histogram{}; + var i: usize = 0; + while (i < 1_000) : (i += 1) { + warmup_h.clear(); + std.mem.doNotOptimizeAway(&warmup_h); + } + } + + var t: usize = 0; + while (t < trials) : (t += 1) { + var h = p99.Histogram{}; + _ = h.pushEventTimeNs(100); + _ = h.pushEventTimeNs(200); + + const start = std.Io.Clock.awake.now(io); + var i: usize = 0; + while (i < loop_count) : (i += 1) { + h.clear(); + std.mem.doNotOptimizeAway(&h); + } + const elapsed = start.untilNow(io, .awake); + total_ns += @as(u64, @intCast(elapsed.nanoseconds)); + } + + const avg_ns = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(loop_count * trials)); + + try stdout.print("Benchmark: Clear\n", .{}); + try stdout.print(" Total clears: {d} (across {d} trials)\n", .{ loop_count * trials, trials }); + try stdout.print(" Average/clear: {d:.2} ns\n\n", .{avg_ns}); + } + + // 5. Benchmark: Generic Percentile Queries (valueAtPercentile) { var h = p99.Histogram{}; const count = 100_000; @@ -61,23 +186,41 @@ pub fn main(init: std.process.Init) !void { var i: u64 = 1; while (i <= count) : (i += 1) { seed = seed *% 6364136223846793005 +% 1442695040888963407; - const val = (seed % 1_000_000) + 1; + const val = (seed % 10_000_000_000) + 1; // 10-second wide-range _ = h.pushEventTimeNs(val); } + std.mem.doNotOptimizeAway(&h); const query_count = 10_000; - const start = std.Io.Clock.awake.now(io); - var q: u64 = 0; - while (q < query_count) : (q += 1) { - _ = h.valueAtP99(); + const trials = 50; + var total_ns: u64 = 0; + + // Warmup + { + var q: u64 = 0; + while (q < 1_000) : (q += 1) { + var val = h.valueAtPercentile(99.0); + std.mem.doNotOptimizeAway(&val); + } } - const elapsed = start.untilNow(io, .awake); - const ns = elapsed.nanoseconds; - const avg_ns = @as(f64, @floatFromInt(ns)) / @as(f64, @floatFromInt(query_count)); - try stdout.print("Benchmark: Percentile Queries (valueAtP99)\n", .{}); - try stdout.print(" Total queries: {d}\n", .{query_count}); - try stdout.print(" Total time: {d} ns\n", .{ns}); + var t: usize = 0; + while (t < trials) : (t += 1) { + const start = std.Io.Clock.awake.now(io); + var q: u64 = 0; + while (q < query_count) : (q += 1) { + std.mem.doNotOptimizeAway(&h); + var val = h.valueAtPercentile(99.0); + std.mem.doNotOptimizeAway(&val); + } + const elapsed = start.untilNow(io, .awake); + total_ns += @as(u64, @intCast(elapsed.nanoseconds)); + } + + const avg_ns = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(query_count * trials)); + + try stdout.print("Benchmark: Generic Percentile Queries (valueAtPercentile(99.0))\n", .{}); + try stdout.print(" Total queries: {d} (across {d} trials)\n", .{ query_count * trials, trials }); try stdout.print(" Average/query: {d:.2} ns\n\n", .{avg_ns}); } diff --git a/build.zig b/build.zig index a64d573..e7f5ba1 100644 --- a/build.zig +++ b/build.zig @@ -4,6 +4,13 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // Build option for binary scaling (mirroring Rust's feature flag) + const binary_scaling = b.option( + bool, + "binary-scaling", + "Enable 2^32 fixed-point binary scaling for integer-based percentile queries (default: false)", + ) orelse false; + // Create a module for other packages to import const p99_module = b.addModule("p99", .{ .root_source_file = b.path("src/root.zig"), @@ -11,6 +18,11 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // Add build options to the module + const options = b.addOptions(); + options.addOption(bool, "binary_scaling", binary_scaling); + p99_module.addOptions("build_options", options); + // Static library using the module const lib = b.addLibrary(.{ .name = "p99", diff --git a/build.zig.zon b/build.zig.zon index 2b5c914..c337aff 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,5 +8,5 @@ "LICENSE", "README.md", }, - .version = "0.0.2", + .version = "0.0.3", } diff --git a/src/root.zig b/src/root.zig index 4407aa6..3a7d92d 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,6 +1,7 @@ // src/root.zig : p99.Zig const std = @import("std"); +const build_options = @import("build_options"); /// Low-cost performance percentile histogram using 64 buckets. /// @@ -248,7 +249,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 90th /// percentile (p90). pub fn valueAtP90(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 90) / 100; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 3_865_470_566) >> 32 + else + (@as(u128, self.event_count) * 90) / 100; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -256,7 +260,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 95th /// percentile (p95). pub fn valueAtP95(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 95) / 100; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_080_218_931) >> 32 + else + (@as(u128, self.event_count) * 95) / 100; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -264,7 +271,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 99th /// percentile (p99). pub fn valueAtP99(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 99) / 100; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_252_017_623) >> 32 + else + (@as(u128, self.event_count) * 99) / 100; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -272,7 +282,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 99.5th /// percentile (p99.5). pub fn valueAtP99_5(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 995) / 1000; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_273_492_460) >> 32 + else + (@as(u128, self.event_count) * 995) / 1000; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -280,7 +293,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 99.9th /// percentile (p99.9). pub fn valueAtP99_9(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 999) / 1000; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_290_672_329) >> 32 + else + (@as(u128, self.event_count) * 999) / 1000; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -288,7 +304,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 99.99th /// percentile (p99.99). pub fn valueAtP99_99(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 9999) / 10000; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_294_537_799) >> 32 + else + (@as(u128, self.event_count) * 9999) / 10000; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -296,7 +315,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 99.999th /// percentile (p99.999). pub fn valueAtP99_999(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 99999) / 100000; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_294_924_346) >> 32 + else + (@as(u128, self.event_count) * 99999) / 100000; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); } @@ -304,7 +326,10 @@ pub const Histogram = struct { /// Returns the approximated duration (in nanoseconds) at the 99.9999th /// percentile (p99.9999). pub fn valueAtP99_999_9(self: *const Histogram) ?u64 { - const target_rank = (@as(u128, self.event_count) * 999999) / 1000000; + const target_rank = if (build_options.binary_scaling) + (@as(u128, self.event_count) * 4_294_963_001) >> 32 + else + (@as(u128, self.event_count) * 999999) / 1000000; return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); }