From f46ac470b2051b29569785c39243534fd309ee4e Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 09:05:23 +1000 Subject: [PATCH 1/7] initial commit --- .gitattributes | 5 +++ .gitignore | 20 +++++++++++ .vimrc | 64 ++++++++++++++++++++++++++++++++++++ .vscode/settings.json | 38 +++++++++++++++++++++ CHANGES.md | 9 +++++ EXAMPLES.md | 4 +++ LICENSE | 30 +++++++++++++++++ NEWS.md | 7 ++++ README.md | 47 ++++++++++++++++++++++++++ TODO.md | 26 +++++++++++++++ build.zig | 62 ++++++++++++++++++++++++++++++++++ build.zig.zon | 12 +++++++ examples/build_histogram.zig | 1 + src/root.zig | 3 ++ 14 files changed, 328 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vimrc create mode 100644 .vscode/settings.json create mode 100644 CHANGES.md create mode 100644 EXAMPLES.md create mode 100644 LICENSE create mode 100644 NEWS.md create mode 100644 README.md create mode 100644 TODO.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 examples/build_histogram.zig create mode 100644 src/root.zig diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c7a1b95 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.zig linguist-language=Zig + +*.html linguist-detectable=false +*.sh linguist-detectable=false +*.vimrc linguist-detectable=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8185211 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# directories (by name) + +/scratch/ +/scripts/__pycache__/ +/.zig-cache/ +/zig-out/ + +# directories (by pattern) + + +# files (by name) + +.DS_Store + +# files (by pattern) + +*~ +*.swp +*.tmp +*.zip diff --git a/.vimrc b/.vimrc new file mode 100644 index 0000000..0f12357 --- /dev/null +++ b/.vimrc @@ -0,0 +1,64 @@ +" Synesis C/C++ & Zig project .vimrc — aligned with .vscode/settings.json + +set nocompatible +filetype indent plugin on +syntax enable +set autoindent +set backspace=indent,eol,start +set hlsearch +set incsearch +set number + +" files.insertFinalNewline +set eol +set fixeol + +" editor.renderWhitespace: all +set list +set listchars=tab:->,trail:-,extends:>,precedes:<,nbsp:+ + +" editor.detectIndentation: false — global defaults (editor.tabSize: 4, insertSpaces: true) +set tabstop=4 +set shiftwidth=4 +set softtabstop=4 +set expandtab +set colorcolumn=76 + +if has('termguicolors') + " set termguicolors +endif + +function! s:ConfigureColorColumn() abort + highlight ColorColumn ctermbg=236 guibg=#2a2a2a cterm=NONE gui=NONE +endfunction + +call s:ConfigureColorColumn() +autocmd ColorScheme * call s:ConfigureColorColumn() + +" files.trimTrailingWhitespace +autocmd BufWritePre * %s/\s\+$//e + +augroup sis_c_cxx_zig + autocmd! + + " [c] / [cpp] + autocmd FileType c,cpp setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=60,64,68,72,76 + + " [zig] + autocmd FileType zig setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=76 + + " [cmake] + autocmd FileType cmake setlocal noexpandtab tabstop=4 shiftwidth=4 softtabstop=4 + + " [shellscript] + autocmd FileType sh,bash,zsh setlocal expandtab tabstop=2 shiftwidth=2 softtabstop=2 colorcolumn=60,76 + + " [bat] + autocmd FileType bat,dosbatch setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=60,76 + + " [json] / [markdown] / [yaml] / [ruby] + autocmd FileType json,markdown,yaml,ruby setlocal expandtab tabstop=2 shiftwidth=2 softtabstop=2 + + " [toml] + autocmd FileType toml setlocal noexpandtab tabstop=2 shiftwidth=2 softtabstop=2 +augroup END diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2abd7ac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + "[json]": { + "editor.insertSpaces": false, + "editor.tabSize": 2, + }, + "[markdown]": { + "editor.insertSpaces": true, + "editor.tabSize": 2, + }, + "[python]": { + "editor.insertSpaces": true, + "editor.rulers": [ 60, 76 ], + "editor.tabSize": 4, + }, + "[ruby]": { + "editor.insertSpaces": true, + "editor.rulers": [ 60, 76 ], + "editor.tabSize": 2, + }, + "[toml]": { + "editor.insertSpaces": false, + "editor.tabSize": 2, + }, + "[zig]": { + "editor.insertSpaces": true, + "editor.tabSize": 4, + "editor.formatOnSave": true, + "editor.defaultFormatter": "ziglang.vscode-zig" + }, + "editor.detectIndentation": false, + "editor.insertSpaces": false, + "editor.renderWhitespace": "all", + "editor.rulers": [ 76 ], + "editor.tabSize": 2, + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + "git.mergeEditor": false, +} diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..d82749f --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,9 @@ +# p99.Zig CHANGES + + +## 0.0.0 - 26th July 2026 + +FIRST PUBLIC RELEASE (BOILERPLATE SKELETON) + + + diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 0000000..5963302 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,4 @@ +# p99.Zig - EXAMPLES + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0711147 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +p99.Zig- BSD 3-Clause License + +Copyright (c) 2026, Matthew Wilson and Synesis Information Systems +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000..6ac78fa --- /dev/null +++ b/NEWS.md @@ -0,0 +1,7 @@ +# p99.Zig - NEWS + +| Date | News Item | +| --------------------- | ----------------------------------------- | + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..69f9ddb --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# p99.Zig + +![Language](https://img.shields.io/badge/Zig-F7A41D?style=flat&logo=zig&logoColor=white) +[![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) +[![GitHub release](https://img.shields.io/github/v/release/synesissoftware/p99.Zig.svg)](https://github.com/synesissoftware/p99.Zig/releases/latest) +[![Last Commit](https://img.shields.io/github/last-commit/synesissoftware/p99.Zig)](https://github.com/synesissoftware/p99.Zig/commits/master) +[![CI](https://github.com/synesissoftware/p99.Zig/actions/workflows/ci.yml/badge.svg)](https://github.com/synesissoftware/p99.Zig/actions/workflows/ci.yml) + +Low-cost generation of performance percentiles (p50, p90, p99, p99.9, etc.). + + +## Table of Contents + +- [Introduction](#introduction) +- [How It Works](#how-it-works) +- [Installation](#installation) +- [Minimal Example](#minimal-example) +- [License](#license) + + +## Introduction + +**p99** is a lightweight, low-overhead library designed for generating real-time performance percentiles in high-frequency or latency-sensitive environments. + +**p99.Zig** is the **Zig** implementation. + + +## How It Works + +T.B.C. + + +## Installation + +Add `p99` to your `build.zig.zon` dependencies. + + +## Minimal Example + +T.B.C. + + +## License + +**p99.Zig** is released under the 3-clause BSD license. See [LICENSE](./LICENSE) for details. + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0aae250 --- /dev/null +++ b/TODO.md @@ -0,0 +1,26 @@ +# p99.Zig - TODO + + +## Table of Contents + +- [Functional improvements](#functional-improvements) +- [Performance improvements](#performance-improvements) +- [Packaging improvements](#packaging-improvements) + + +## Functional improvements + +- T.B.C. + + +## Performance improvements + +- T.B.C. + + +## Packaging improvements + +- T.B.C. + + + diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..52c968f --- /dev/null +++ b/build.zig @@ -0,0 +1,62 @@ +const std = @import("std"); + +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"), + .target = target, + .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", + .linkage = .static, + .root_module = p99_module, + }); + b.installArtifact(lib); + + // Unit tests using the module + const lib_unit_tests = b.addTest(.{ + .root_module = p99_module, + }); + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_unit_tests.step); + + // Example: build_histogram + const example = b.addExecutable(.{ + .name = "build_histogram", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/build_histogram.zig"), + .target = target, + .optimize = optimize, + }), + }); + example.root_module.addImport("p99", p99_module); + b.installArtifact(example); + + const run_example = b.addRunArtifact(example); + if (b.args) |args| { + run_example.addArgs(args); + } + + const run_example_step = b.step("run-example", "Run the build_histogram example"); + run_example_step.dependOn(&run_example.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..56049c0 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .fingerprint = 0xfa31b4f0ad2d50c3, + .name = .p99, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + }, + .version = "0.0.0", +} diff --git a/examples/build_histogram.zig b/examples/build_histogram.zig new file mode 100644 index 0000000..902b554 --- /dev/null +++ b/examples/build_histogram.zig @@ -0,0 +1 @@ +pub fn main() void {} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..a9c7321 --- /dev/null +++ b/src/root.zig @@ -0,0 +1,3 @@ +// p99.Zig - Low-cost performance percentiles +// +// This is a zero-code skeleton for the library. From 1836511a0ff79314f3030aabebbb60f425b84fc6 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 12:17:03 +1000 Subject: [PATCH 2/7] Implement core Histogram logic and tests (v0.0.1) Implement the core 64-bucket logarithmic performance percentile histogram (`Histogram`) for Step 1. This includes branchless bucket indexing via CPU `@clz` (count leading zeros) and linear interpolation for percentile approximation. Add a comprehensive unit test suite covering bucket index/range calculations, event pushing, overflow handling, and percentile interpolation. Add a benchmark suite using the modern Zig 0.16.0 `std.Io.Clock` API to measure raw CPU execution times, along with a minimal usage example. Refactor all executable outputs to use standard buffered `stdout` writers instead of `std.debug.print` (which writes to `stderr`). Update `README.md` with idiomatic `zig fetch` installation instructions, project metadata, and a list of related `p99` ports. Document upcoming tasks in `TODO.md` and bump the package version to `0.0.1` in `build.zig.zon`. --- CHANGES.md | 16 +- NEWS.md | 1 + README.md | 119 +++++- TODO.md | 8 +- benches/benchmark_histogram.zig | 85 ++++ build.zig | 32 +- build.zig.zon | 2 +- examples/build_histogram.zig | 35 +- src/root.zig | 668 +++++++++++++++++++++++++++++++- 9 files changed, 939 insertions(+), 27 deletions(-) create mode 100644 benches/benchmark_histogram.zig diff --git a/CHANGES.md b/CHANGES.md index d82749f..9f337eb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,23 @@ # p99.Zig CHANGES +## 0.0.1 - 26th July 2026 + +Core logic + +* Implemented core `Histogram` struct with 64-bucket logarithmic power-of-two spacing. +* Implemented branchless bucket indexing via CPU `@clz` (count leading zeros) instruction. +* Implemented linear interpolation for percentile approximation. +* Added comprehensive unit tests covering default initialization, bucket calculations, event pushing, overflow handling, and percentile accuracy. +* Added benchmark suite using the modern Zig 0.16.0 `std.Io.Clock` API. +* Added example program demonstrating basic usage. +* Refactored all examples, benchmarks, and client code to use standard `stdout` buffered writing. +* Updated `README.md` with standard installation instructions and minimal example. + + ## 0.0.0 - 26th July 2026 -FIRST PUBLIC RELEASE (BOILERPLATE SKELETON) +First public release (boilerplate skeleton) diff --git a/NEWS.md b/NEWS.md index 6ac78fa..95ec1be 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,7 @@ | Date | News Item | | --------------------- | ----------------------------------------- | +| 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 11c9de9..55433e2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,13 @@ Very low-cost measuring of performance percentiles, for Zig - [How It Works](#how-it-works) - [Installation](#installation) - [Minimal Example](#minimal-example) -- [License](#license) +- [Project Information](#project-information) + - [Where to get help](#where-to-get-help) + - [Contribution guidelines](#contribution-guidelines) + - [Dependencies](#dependencies) + - [Dev Dependencies](#dev-dependencies) + - [Related Projects](#related-projects) + - [License](#license) ## Introduction @@ -27,22 +33,123 @@ Very low-cost measuring of performance percentiles, for Zig ## How It Works -T.B.C. +`p99.Zig` uses a fixed-size, zero-allocation logarithmic histogram with exactly 64 buckets. + +Each bucket represents a power-of-two range of nanoseconds: +- Bucket `0` represents `[0, 1]` nanoseconds; +- Bucket `1` represents `[2, 3]` nanoseconds; +- Bucket `2` represents `[4, 7]` nanoseconds; +- ... +- Bucket `63` represents `[2^63, 2^64 - 1]` nanoseconds. + +Finding the bucket index is extremely fast and branchless, implemented using the CPU's count leading zeros instruction (`@clz`). + +When querying percentiles (e.g., P50, P99), the library performs linear interpolation within the target bucket to approximate the duration with high accuracy. ## Installation -Add `p99` to your `build.zig.zon` dependencies. +The recommended way to add `p99.Zig` to your project is using the `zig fetch` command. Run the following in your project root: + +```bash +zig fetch --save https://github.com/synesissoftware/p99.Zig/archive/refs/tags/v0.0.1.tar.gz +``` + +This will automatically download the package, compute its hash, and add it to your `build.zig.zon` dependencies: + +```zig +.{ + .name = .my_project, + .version = "0.1.0", + .dependencies = .{ + .p99 = .{ + .url = "https://github.com/synesissoftware/p99.Zig/archive/refs/tags/v0.0.1.tar.gz", + .hash = "1220...", // Automatically calculated by zig fetch + }, + }, +} +``` + +Then, expose the dependency in your `build.zig`: + +```zig +const p99_dep = b.dependency("p99", .{ + .target = target, + .optimize = optimize, +}); +exe.root_module.addImport("p99", p99_dep.module("p99")); +``` ## Minimal Example -T.B.C. +Here is a minimal example demonstrating how to use `p99.Zig`: + +```zig +const std = @import("std"); +const p99 = @import("p99"); + +pub fn main(init: std.process.Init) !void { + var buffer: [4096]u8 = undefined; + var stdout_impl = std.Io.File.stdout().writer(init.io, &buffer); + const stdout = &stdout_impl.interface; + + var h = p99.Histogram{}; + + // Push events (durations in nanoseconds) + _ = h.pushEventTimeNs(100); + _ = h.pushEventTimeNs(250); + _ = h.pushEventTimeNs(500); + _ = h.pushEventTimeNs(1000); + _ = h.pushEventTimeNs(5000); + + // Query percentiles + const p50 = h.valueAtP50().?; + const p99_val = h.valueAtP99().?; + + try stdout.print("P50: {d} ns\n", .{p50}); + try stdout.print("P99: {d} ns\n", .{p99_val}); + + try stdout.flush(); +} +``` + + +## Project Information -## License +### Where to get help + +[GitHub Page](https://github.com/synesissoftware/p99.Zig "GitHub Page") + + +### Contribution guidelines + +Defect reports, feature requests, and pull requests are welcome on https://github.com/synesissoftware/p99.Zig. + + +### Dependencies + +**p99.Zig** has no (non-development) dependencies beyond the Zig standard library. + + +#### Dev Dependencies + +**p99.Zig** has no development dependencies beyond the Zig standard library. + + +### Related Projects + +Other implementations of the **p99** specification include: + +* [**p99** (C)](https://github.com/synesissoftware/p99); +* [**p99.Go** (Go)](https://github.com/synesissoftware/p99.Go); +* [**p99.Python** (Python)](https://github.com/synesissoftware/p99.Python); +* [**p99.Rust** (Rust)](https://github.com/synesissoftware/p99.Rust). + + +### License **p99.Zig** is released under the 3-clause BSD license. See [LICENSE](./LICENSE) for details. - diff --git a/TODO.md b/TODO.md index 0aae250..721e8c7 100644 --- a/TODO.md +++ b/TODO.md @@ -10,17 +10,19 @@ ## Functional improvements -- T.B.C. +- [ ] Support custom percentile lists or dynamic percentile inputs. +- [ ] Add serialization and deserialization support for the `Histogram` struct. ## Performance improvements -- T.B.C. +- [ ] Implement $2^{32}$ fixed-point binary scaling float-avoidance optimizations from `p99.Rust` as a comptime build option. ## Packaging improvements -- T.B.C. +- [ ] Publish the package to Zig package registries. +- [ ] Set up a GitHub Actions CI/CD pipeline for automated testing. diff --git a/benches/benchmark_histogram.zig b/benches/benchmark_histogram.zig new file mode 100644 index 0000000..458a158 --- /dev/null +++ b/benches/benchmark_histogram.zig @@ -0,0 +1,85 @@ +const std = @import("std"); +const p99 = @import("p99"); + +pub fn main(init: std.process.Init) !void { + const io = init.io; + var buffer: [4096]u8 = undefined; + var stdout_impl = std.Io.File.stdout().writer(io, &buffer); + const stdout = &stdout_impl.interface; + + try stdout.print("p99.Zig Benchmark Suite starting...\n\n", .{}); + + // 1. Benchmark: Pushing sequential events + { + var h = p99.Histogram{}; + const count = 100_000; + + const start = std.Io.Clock.awake.now(io); + var i: u64 = 1; + while (i <= count) : (i += 1) { + _ = h.pushEventTimeNs(i); + } + const elapsed = start.untilNow(io, .awake); + const ns = elapsed.nanoseconds; + const avg_ns = @as(f64, @floatFromInt(ns)) / @as(f64, @floatFromInt(count)); + + 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(" Average/push: {d:.2} ns\n\n", .{avg_ns}); + } + + // 2. Benchmark: Pushing random events (using a simple LCG PRNG for speed) + { + var h = p99.Histogram{}; + const count = 100_000; + var seed: u64 = 12345; + + const start = std.Io.Clock.awake.now(io); + var i: u64 = 1; + while (i <= count) : (i += 1) { + // Simple LCG PRNG + seed = seed *% 6364136223846793005 +% 1442695040888963407; + const val = (seed % 1_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)); + + 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}); + } + + // 3. Benchmark: Percentile Queries + { + var h = p99.Histogram{}; + const count = 100_000; + var seed: u64 = 12345; + var i: u64 = 1; + while (i <= count) : (i += 1) { + seed = seed *% 6364136223846793005 +% 1442695040888963407; + const val = (seed % 1_000_000) + 1; + _ = h.pushEventTimeNs(val); + } + + 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 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}); + try stdout.print(" Average/query: {d:.2} ns\n\n", .{avg_ns}); + } + + try stdout.flush(); +} diff --git a/build.zig b/build.zig index 52c968f..a64d573 100644 --- a/build.zig +++ b/build.zig @@ -4,13 +4,6 @@ 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"), @@ -18,11 +11,6 @@ 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", @@ -59,4 +47,24 @@ pub fn build(b: *std.Build) void { const run_example_step = b.step("run-example", "Run the build_histogram example"); run_example_step.dependOn(&run_example.step); + + // Benchmark: benchmark_histogram + const bench = b.addExecutable(.{ + .name = "benchmark_histogram", + .root_module = b.createModule(.{ + .root_source_file = b.path("benches/benchmark_histogram.zig"), + .target = target, + .optimize = optimize, + }), + }); + bench.root_module.addImport("p99", p99_module); + b.installArtifact(bench); + + const run_bench = b.addRunArtifact(bench); + if (b.args) |args| { + run_bench.addArgs(args); + } + + const run_bench_step = b.step("bench", "Run the benchmark_histogram suite"); + run_bench_step.dependOn(&run_bench.step); } diff --git a/build.zig.zon b/build.zig.zon index 56049c0..e5c217b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,5 +8,5 @@ "LICENSE", "README.md", }, - .version = "0.0.0", + .version = "0.0.1", } diff --git a/examples/build_histogram.zig b/examples/build_histogram.zig index 902b554..6610765 100644 --- a/examples/build_histogram.zig +++ b/examples/build_histogram.zig @@ -1 +1,34 @@ -pub fn main() void {} +const std = @import("std"); +const p99 = @import("p99"); + +pub fn main(init: std.process.Init) !void { + var buffer: [4096]u8 = undefined; + var stdout_impl = std.Io.File.stdout().writer(init.io, &buffer); + const stdout = &stdout_impl.interface; + + try stdout.print("p99.Zig Example: Building a Histogram\n\n", .{}); + + var h = p99.Histogram{}; + + // Push some latency measurements (in nanoseconds) + _ = h.pushEventTimeNs(100); + _ = h.pushEventTimeNs(250); + _ = h.pushEventTimeNs(500); + _ = h.pushEventTimeNs(1000); + _ = h.pushEventTimeNs(5000); + + // Print the histogram using our custom format implementation + try stdout.print("Histogram state:\n{f}\n\n", .{h}); + + // Query percentiles + const p50 = h.valueAtP50().?; + const p90 = h.valueAtP90().?; + const p99_val = h.valueAtP99().?; + + try stdout.print("Percentiles:\n", .{}); + try stdout.print(" P50: {d} ns\n", .{p50}); + try stdout.print(" P90: {d} ns\n", .{p90}); + try stdout.print(" P99: {d} ns\n", .{p99_val}); + + try stdout.flush(); +} diff --git a/src/root.zig b/src/root.zig index a9c7321..4407aa6 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,3 +1,665 @@ -// p99.Zig - Low-cost performance percentiles -// -// This is a zero-code skeleton for the library. +// src/root.zig : p99.Zig + +const std = @import("std"); + +/// Low-cost performance percentile histogram using 64 buckets. +/// +/// Tracks event durations with nanosecond precision across 64 logarithmic +/// power-of-two spacing buckets. This is extremely efficient and suited +/// for high-frequency low-overhead timing measurements. +pub const Histogram = struct { + event_count: u64 = 0, + event_time_total: u64 = 0, + has_overflowed: bool = false, + min_event_time: ?u64 = null, + max_event_time: ?u64 = null, + buckets: [64]u64 = [_]u64{0} ** 64, + + // API (class) methods + + /// Calculates the bucket index for a given elapsed time in nanoseconds. + /// + /// The index is computed logarithmic-wise based on the power of two of + /// the value. Specifically, it maps: + /// - `0` and `1` to bucket `0`; + /// - `2` and `3` to bucket `1`; + /// - `4` to `7` to bucket `2`; + /// - `8` to `15` to bucket `3`; + /// - ... + /// - `(1 << 63)` to `u64::MAX` to bucket `63`; + /// + /// This is extremely fast because it is implemented via the CPU's + /// `@clz` (count leading zeros) instruction, avoiding loop and + /// branching logic. + pub fn bucketIndex(time_in_ns: u64) usize { + if (time_in_ns <= 1) { + return 0; + } + + return @as(usize, 64 - @clz(time_in_ns) - 1); + } + + /// Returns the inclusive range `(lower_bound, upper_bound)` of + /// nanoseconds represented by the given bucket index. + /// + /// - Index `0` represents `[0, 1]` nanoseconds; + /// - Any index `i` from `1` to `63` represents `[2^i, 2^(i+1) - 1]`; + pub fn bucketRange(index: usize) ?[2]u64 { + if (index >= 64) { + return null; + } + + if (index == 0) { + return [2]u64{ 0, 1 }; + } + + const lower = @as(u64, 1) << @intCast(index); + const upper = if (index == 63) std.math.maxInt(u64) else (@as(u64, 1) << @intCast(index + 1)) - 1; + + return [2]u64{ lower, upper }; + } + + // Mutating methods + + /// Clears the instance, resetting all values to the equivalent of a + /// newly constructed instance. + pub fn clear(self: *Histogram) void { + self.* = .{}; + } + + /// Pushes an event with the given duration in nanoseconds. + pub fn pushEventTimeNs(self: *Histogram, time_in_ns: u64) bool { + if (self.tryAddNsToTotalAndUpdateMinMax_(time_in_ns)) { + self.event_count += 1; + + const bucket = bucketIndex(time_in_ns); + self.buckets[bucket] += 1; + + return true; + } + + return false; + } + + /// Pushes an event with the given duration in microseconds. + pub fn pushEventTimeUs(self: *Histogram, time_in_us: u64) bool { + const res = @mulWithOverflow(time_in_us, 1_000); + if (res[1] != 0) { + self.has_overflowed = true; + + return false; + } + + return self.pushEventTimeNs(res[0]); + } + + /// Pushes an event with the given duration in milliseconds. + pub fn pushEventTimeMs(self: *Histogram, time_in_ms: u64) bool { + const res = @mulWithOverflow(time_in_ms, 1_000_000); + if (res[1] != 0) { + self.has_overflowed = true; + + return false; + } + + return self.pushEventTimeNs(res[0]); + } + + /// Pushes an event with the given duration in seconds. + pub fn pushEventTimeS(self: *Histogram, time_in_s: u64) bool { + const res = @mulWithOverflow(time_in_s, 1_000_000_000); + if (res[1] != 0) { + self.has_overflowed = true; + + return false; + } + + return self.pushEventTimeNs(res[0]); + } + + // Non-mutating methods + + /// Returns the count of events in a specific bucket. + pub fn bucketValue(self: *const Histogram, index: usize) ?u64 { + if (index < 64) { + return self.buckets[index]; + } + + return null; + } + + /// Returns a reference to all 64 buckets. + pub fn getBuckets(self: *const Histogram) *const [64]u64 { + return &self.buckets; + } + + /// Number of events counted. + pub fn eventCount(self: *const Histogram) u64 { + return self.event_count; + } + + /// Returns the total event time in nanoseconds, if no overflow + /// occurred. + pub fn eventTimeTotal(self: *const Histogram) ?u64 { + if (self.has_overflowed) { + return null; + } + + return self.event_time_total; + } + + /// Returns the total event time in nanoseconds, regardless of whether + /// overflow has occurred. + pub fn eventTimeTotalRaw(self: *const Histogram) u64 { + return self.event_time_total; + } + + /// Indicates whether overflow has occurred. + pub fn hasOverflowed(self: *const Histogram) bool { + return self.has_overflowed; + } + + /// Returns the minimum event time observed, if any. + pub fn minEventTime(self: *const Histogram) ?u64 { + return self.min_event_time; + } + + /// Returns the maximum event time observed, if any. + pub fn maxEventTime(self: *const Histogram) ?u64 { + return self.max_event_time; + } + + /// Returns the approximated duration (in nanoseconds) at the given + /// percentile. + pub fn valueAtPercentile(self: *const Histogram, percentile: f64) ?u64 { + if (self.event_count == 0) { + return null; + } + + const p = std.math.clamp(percentile, @as(f64, 0.0), @as(f64, 100.0)); + + if (p <= 0.0) { + return self.min_event_time; + } + + if (p >= 100.0) { + return self.max_event_time; + } + + const target_rank = @as(f64, @floatFromInt(self.event_count)) * (p / 100.0); + var accumulated: u64 = 0; + + for (self.buckets, 0..) |count, i| { + if (count > 0) { + const prev_accumulated = accumulated; + accumulated += count; + + if (@as(f64, @floatFromInt(accumulated)) >= target_rank) { + const range = bucketRange(i) orelse return null; + const lower = range[0]; + const upper = range[1]; + + const target_offset = target_rank - @as(f64, @floatFromInt(prev_accumulated)); + const range_width = if (i == 63) + @as(f64, @floatFromInt(std.math.maxInt(u64) - lower)) + else + @as(f64, @floatFromInt(upper - lower)); + + const fraction = target_offset / @as(f64, @floatFromInt(count)); + const interpolated = @as(f64, @floatFromInt(lower)) + (range_width * fraction); + var value = @as(u64, @intFromFloat(std.math.round(interpolated))); + + if (self.min_event_time) |min| { + if (value < min) { + value = min; + } + } + + if (self.max_event_time) |max| { + if (value > max) { + value = max; + } + } + + return value; + } + } + } + + return self.max_event_time; + } + + /// Returns the approximated duration (in nanoseconds) at the 50th + /// percentile (p50). + pub fn valueAtP50(self: *const Histogram) ?u64 { + const target_rank = (@as(u128, self.event_count) * 1) / 2; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// Returns the approximated duration (in nanoseconds) at the 75th + /// percentile (p75). + pub fn valueAtP75(self: *const Histogram) ?u64 { + const target_rank = (@as(u128, self.event_count) * 3) / 4; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// 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; + + return self.valueAtTargetRankImpl(@as(u64, @intCast(target_rank))); + } + + /// Custom format function for printing the Histogram. + pub fn format( + self: Histogram, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.print("Histogram{{ .event_count = {d}, .event_time_total = ", .{self.event_count}); + + if (self.eventTimeTotal()) |total| { + try writer.print("{d}", .{total}); + } else { + try writer.writeAll("null"); + } + + try writer.print(", .has_overflowed = {}, .min_event_time = ", .{self.has_overflowed}); + + if (self.min_event_time) |min| { + try writer.print("{d}", .{min}); + } else { + try writer.writeAll("null"); + } + + try writer.print(", .max_event_time = ", .{}); + + if (self.max_event_time) |max| { + try writer.print("{d}", .{max}); + } else { + try writer.writeAll("null"); + } + + try writer.writeAll(", .buckets = { "); + + var first = true; + + for (self.buckets, 0..) |count, i| { + if (count > 0) { + if (!first) { + try writer.writeAll(", "); + } + + first = false; + + try writer.print("\"{d}\": {d}", .{ i, count }); + } + } + + try writer.writeAll(" } }"); + } + + // Implementation + + fn tryAddNsToTotalAndUpdateMinMax_(self: *Histogram, time_in_ns: u64) bool { + if (self.has_overflowed) { + return false; + } + + const res = @addWithOverflow(self.event_time_total, time_in_ns); + if (res[1] != 0) { + self.has_overflowed = true; + + return false; + } + + self.event_time_total = res[0]; + + if (self.min_event_time) |min_val| { + if (time_in_ns < min_val) { + self.min_event_time = time_in_ns; + } + } else { + self.min_event_time = time_in_ns; + } + + if (self.max_event_time) |max_val| { + if (time_in_ns > max_val) { + self.max_event_time = time_in_ns; + } + } else { + self.max_event_time = time_in_ns; + } + + return true; + } + + fn valueAtTargetRankImpl(self: *const Histogram, target_rank: u64) ?u64 { + if (self.event_count == 0) { + return null; + } + + var accumulated: u64 = 0; + + for (self.buckets, 0..) |count, i| { + if (count > 0) { + const prev_accumulated = accumulated; + accumulated += count; + + if (accumulated >= target_rank) { + const range = bucketRange(i) orelse return null; + const lower = range[0]; + const upper = range[1]; + + const target_offset = target_rank - prev_accumulated; + + const interpolated = if (target_offset == 0) + lower + else blk: { + const range_width = if (i == 63) std.math.maxInt(u64) - lower else upper - lower; + + if (range_width <= std.math.maxInt(u64) / @max(target_offset, 1)) { + break :blk lower + (range_width * target_offset) / count; + } else { + const val_u128 = @as(u128, lower) + (@as(u128, range_width) * @as(u128, target_offset)) / @as(u128, count); + + break :blk @as(u64, @intCast(val_u128)); + } + }; + + var value = interpolated; + + if (self.min_event_time) |min| { + if (value < min) { + value = min; + } + } + + if (self.max_event_time) |max| { + if (value > max) { + value = max; + } + } + + return value; + } + } + } + + return self.max_event_time; + } +}; + +test "TEST_Histogram_Default" { + const h = Histogram{}; + + try std.testing.expectEqual(@as(u64, 0), h.eventCount()); + try std.testing.expectEqual(@as(?u64, 0), h.eventTimeTotal()); + try std.testing.expectEqual(@as(u64, 0), h.eventTimeTotalRaw()); + try std.testing.expect(!h.hasOverflowed()); + try std.testing.expectEqual(@as(?u64, null), h.minEventTime()); + try std.testing.expectEqual(@as(?u64, null), h.maxEventTime()); +} + +test "TEST_Histogram_bucketIndex" { + try std.testing.expectEqual(@as(usize, 0), Histogram.bucketIndex(0)); + try std.testing.expectEqual(@as(usize, 0), Histogram.bucketIndex(1)); + try std.testing.expectEqual(@as(usize, 1), Histogram.bucketIndex(2)); + try std.testing.expectEqual(@as(usize, 1), Histogram.bucketIndex(3)); + try std.testing.expectEqual(@as(usize, 2), Histogram.bucketIndex(4)); + try std.testing.expectEqual(@as(usize, 2), Histogram.bucketIndex(7)); + try std.testing.expectEqual(@as(usize, 3), Histogram.bucketIndex(8)); + try std.testing.expectEqual(@as(usize, 3), Histogram.bucketIndex(15)); + try std.testing.expectEqual(@as(usize, 4), Histogram.bucketIndex(16)); + try std.testing.expectEqual(@as(usize, 4), Histogram.bucketIndex(31)); + try std.testing.expectEqual(@as(usize, 10), Histogram.bucketIndex(1024)); + try std.testing.expectEqual(@as(usize, 10), Histogram.bucketIndex(2047)); + try std.testing.expectEqual(@as(usize, 63), Histogram.bucketIndex(1 << 63)); + try std.testing.expectEqual(@as(usize, 63), Histogram.bucketIndex(std.math.maxInt(u64))); +} + +test "TEST_Histogram_bucketRange" { + try std.testing.expectEqual([2]u64{ 0, 1 }, Histogram.bucketRange(0).?); + try std.testing.expectEqual([2]u64{ 2, 3 }, Histogram.bucketRange(1).?); + try std.testing.expectEqual([2]u64{ 4, 7 }, Histogram.bucketRange(2).?); + try std.testing.expectEqual([2]u64{ 8, 15 }, Histogram.bucketRange(3).?); + try std.testing.expectEqual([2]u64{ 16, 31 }, Histogram.bucketRange(4).?); + try std.testing.expectEqual([2]u64{ 1024, 2047 }, Histogram.bucketRange(10).?); + try std.testing.expectEqual([2]u64{ 1 << 63, std.math.maxInt(u64) }, Histogram.bucketRange(63).?); + try std.testing.expect(Histogram.bucketRange(64) == null); +} + +test "TEST_Histogram_PUSH_EVENTS" { + var h = Histogram{}; + + try std.testing.expect(h.pushEventTimeNs(1)); + try std.testing.expect(h.pushEventTimeNs(3)); + try std.testing.expect(h.pushEventTimeUs(10)); + try std.testing.expect(h.pushEventTimeMs(5)); + try std.testing.expect(h.pushEventTimeS(2)); + + try std.testing.expectEqual(@as(u64, 5), h.eventCount()); + try std.testing.expect(!h.hasOverflowed()); + try std.testing.expectEqual(@as(?u64, 1), h.minEventTime()); + try std.testing.expectEqual(@as(?u64, 2_000_000_000), h.maxEventTime()); + try std.testing.expectEqual(@as(?u64, 2_005_010_004), h.eventTimeTotal()); + + try std.testing.expectEqual(@as(u64, 1), h.buckets[0]); + try std.testing.expectEqual(@as(u64, 1), h.buckets[1]); + try std.testing.expectEqual(@as(u64, 1), h.buckets[13]); + try std.testing.expectEqual(@as(u64, 1), h.buckets[22]); + try std.testing.expectEqual(@as(u64, 1), h.buckets[30]); + + h.clear(); + + try std.testing.expectEqual(@as(u64, 0), h.eventCount()); + try std.testing.expectEqual(@as(?u64, 0), h.eventTimeTotal()); +} + +test "TEST_Histogram_OVERFLOW" { + var h = Histogram{}; + + try std.testing.expect(h.pushEventTimeNs(std.math.maxInt(u64))); + try std.testing.expectEqual(@as(?u64, std.math.maxInt(u64)), h.eventTimeTotal()); + try std.testing.expect(!h.hasOverflowed()); + + try std.testing.expect(!h.pushEventTimeNs(1)); + try std.testing.expect(h.hasOverflowed()); + try std.testing.expectEqual(@as(?u64, null), h.eventTimeTotal()); + try std.testing.expectEqual(@as(u64, std.math.maxInt(u64)), h.eventTimeTotalRaw()); +} + +test "TEST_Histogram_PERCENTILES_EMPTY" { + const h = Histogram{}; + + try std.testing.expectEqual(@as(?u64, null), h.valueAtPercentile(50.0)); + try std.testing.expectEqual(@as(?u64, null), h.valueAtP50()); + try std.testing.expectEqual(@as(?u64, null), h.valueAtP99()); +} + +test "TEST_Histogram_PERCENTILES_SINGLE_EVENT" { + var h = Histogram{}; + + try std.testing.expect(h.pushEventTimeNs(100)); + + try std.testing.expectEqual(@as(?u64, 100), h.valueAtPercentile(0.0)); + try std.testing.expectEqual(@as(?u64, 100), h.valueAtPercentile(50.0)); + try std.testing.expectEqual(@as(?u64, 100), h.valueAtPercentile(99.0)); + try std.testing.expectEqual(@as(?u64, 100), h.valueAtPercentile(100.0)); + + try std.testing.expectEqual(@as(?u64, 100), h.valueAtP50()); + try std.testing.expectEqual(@as(?u64, 100), h.valueAtP90()); + try std.testing.expectEqual(@as(?u64, 100), h.valueAtP99()); + try std.testing.expectEqual(@as(?u64, 100), h.valueAtP99_999_9()); +} + +test "TEST_Histogram_PERCENTILES_INTERPOLATION" { + var h = Histogram{}; + + try std.testing.expect(h.pushEventTimeNs(100)); // Bucket 6 [64, 127] + try std.testing.expect(h.pushEventTimeNs(200)); // Bucket 7 [128, 255] + + const p50 = h.valueAtPercentile(50.0); + const p99 = h.valueAtPercentile(99.0); + + try std.testing.expect(p50 != null); + try std.testing.expect(p99 != null); + + try std.testing.expect(p50.? >= 100 and p50.? <= 200); + try std.testing.expect(p99.? >= 100 and p99.? <= 200); + + try std.testing.expectEqual(@as(?u64, 100), h.valueAtPercentile(0.0)); + try std.testing.expectEqual(@as(?u64, 200), h.valueAtPercentile(100.0)); + + try std.testing.expect(h.valueAtP50().? >= 100); + try std.testing.expect(h.valueAtP99().? <= 200); +} + +test "TEST_Histogram_PERCENTILES_WIDE_RANGE" { + var h = Histogram{}; + + const values = [_]u64{ + 1, + 10, + 100, + 1_000, + 10_000, + 100_000, + 1_000_000, + 10_000_000, + 100_000_000, + 1_000_000_000, + 10_000_000_000, + }; + + for (values) |v| { + try std.testing.expect(h.pushEventTimeNs(v)); + } + + try std.testing.expectEqual(@as(u64, values.len), h.eventCount()); + try std.testing.expectEqual(@as(?u64, 1), h.minEventTime()); + try std.testing.expectEqual(@as(?u64, 10_000_000_000), h.maxEventTime()); + + const p50 = h.valueAtP50().?; + const p75 = h.valueAtP75().?; + const p90 = h.valueAtP90().?; + const p95 = h.valueAtP95().?; + const p99 = h.valueAtP99().?; + const p99_5 = h.valueAtP99_5().?; + const p99_9 = h.valueAtP99_9().?; + const p99_99 = h.valueAtP99_99().?; + const p99_999 = h.valueAtP99_999().?; + const p99_999_9 = h.valueAtP99_999_9().?; + + try std.testing.expect(p50 <= p75); + try std.testing.expect(p75 <= p90); + try std.testing.expect(p90 <= p95); + try std.testing.expect(p95 <= p99); + try std.testing.expect(p99 <= p99_5); + try std.testing.expect(p99_5 <= p99_9); + try std.testing.expect(p99_9 <= p99_99); + try std.testing.expect(p99_99 <= p99_999); + try std.testing.expect(p99_999 <= p99_999_9); + + try std.testing.expect(p50 >= 1); + try std.testing.expect(p99_999_9 <= 10_000_000_000); +} + +test "TEST_Histogram_PERCENTILES_MANY_EVENTS" { + var h = Histogram{}; + const count = 100_000; + + var i: u64 = 1; + while (i <= count) : (i += 1) { + try std.testing.expect(h.pushEventTimeNs(i)); + } + + try std.testing.expectEqual(@as(u64, count), h.eventCount()); + try std.testing.expectEqual(@as(?u64, 1), h.minEventTime()); + try std.testing.expectEqual(@as(?u64, count), h.maxEventTime()); + + const p50 = h.valueAtP50().?; + const p90 = h.valueAtP90().?; + const p99 = h.valueAtP99().?; + const p99_9 = h.valueAtP99_9().?; + + try std.testing.expectEqual(@as(u64, 50_000), p50); + try std.testing.expectEqual(@as(u64, 100_000), p90); + try std.testing.expectEqual(@as(u64, 100_000), p99); + try std.testing.expectEqual(@as(u64, 100_000), p99_9); + + const p75 = h.valueAtP75().?; + const p95 = h.valueAtP95().?; + const p99_5 = h.valueAtP99_5().?; + const p99_99 = h.valueAtP99_99().?; + const p99_999 = h.valueAtP99_999().?; + const p99_999_9 = h.valueAtP99_999_9().?; + + try std.testing.expect(p50 <= p75); + try std.testing.expect(p75 <= p90); + try std.testing.expect(p90 <= p95); + try std.testing.expect(p95 <= p99); + try std.testing.expect(p99 <= p99_5); + try std.testing.expect(p99_5 <= p99_9); + try std.testing.expect(p99_9 <= p99_99); + try std.testing.expect(p99_99 <= p99_999); + try std.testing.expect(p99_999 <= p99_999_9); +} From cceb72281a4b874eb18e4437c4f54a327f9c422e Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 12:25:13 +1000 Subject: [PATCH 3/7] Add GitHub Actions CI support (v0.0.2) Add a GitHub Actions CI workflow (`.github/workflows/ci.yml`) to automatically run formatting checks, library unit tests, example runs, and benchmark suites across Linux (Ubuntu), macOS, and Windows. Bump the package version to `0.0.2` in `build.zig.zon`. Update `CHANGES.md`, `NEWS.md`, and `TODO.md` to document the release and mark the CI setup task as completed. --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ CHANGES.md | 26 ++++++++++++------ NEWS.md | 1 + TODO.md | 2 +- build.zig.zon | 2 +- 5 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1f38d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: + - master + - dev + - v1 + pull_request: + branches: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - name: Check Formatting + run: zig fmt --check . + + test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - name: Run Unit Tests + run: zig build test --global-cache-dir .zig-cache + + - name: Run Example + run: zig build run-example --global-cache-dir .zig-cache + + - name: Run Benchmarks + run: zig build bench --global-cache-dir .zig-cache diff --git a/CHANGES.md b/CHANGES.md index 9f337eb..4233da5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,18 +1,28 @@ # p99.Zig CHANGES +## 0.0.2 - 26th July 2026 + +GitHub Actions CI + +* Added GitHub Actions CI workflow (`.github/workflows/ci.yml`) supporting Linux, macOS, and Windows; +* Configured CI to run formatting checks (`zig fmt --check .`); +* Configured CI to run unit tests, compile and run the example, and run the benchmark suite; +* Bumped package version to `0.0.2`; + + ## 0.0.1 - 26th July 2026 Core logic -* Implemented core `Histogram` struct with 64-bucket logarithmic power-of-two spacing. -* Implemented branchless bucket indexing via CPU `@clz` (count leading zeros) instruction. -* Implemented linear interpolation for percentile approximation. -* Added comprehensive unit tests covering default initialization, bucket calculations, event pushing, overflow handling, and percentile accuracy. -* Added benchmark suite using the modern Zig 0.16.0 `std.Io.Clock` API. -* Added example program demonstrating basic usage. -* Refactored all examples, benchmarks, and client code to use standard `stdout` buffered writing. -* Updated `README.md` with standard installation instructions and minimal example. +* Implemented core `Histogram` struct with 64-bucket logarithmic power-of-two spacing; +* Implemented branchless bucket indexing via CPU `@clz` (count leading zeros) instruction; +* Implemented linear interpolation for percentile approximation; +* Added comprehensive unit tests covering default initialization, bucket calculations, event pushing, overflow handling, and percentile accuracy; +* Added benchmark suite using the modern Zig 0.16.0 `std.Io.Clock` API; +* Added example program demonstrating basic usage; +* Refactored all examples, benchmarks, and client code to use standard `stdout` buffered writing; +* Updated `README.md` with standard installation instructions and minimal example; ## 0.0.0 - 26th July 2026 diff --git a/NEWS.md b/NEWS.md index 95ec1be..ddfe7bb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,7 @@ | Date | News Item | | --------------------- | ----------------------------------------- | +| 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/TODO.md b/TODO.md index 721e8c7..1379997 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ ## Packaging improvements - [ ] Publish the package to Zig package registries. -- [ ] Set up a GitHub Actions CI/CD pipeline for automated testing. +- [x] Set up a GitHub Actions CI/CD pipeline for automated testing. diff --git a/build.zig.zon b/build.zig.zon index e5c217b..2b5c914 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,5 +8,5 @@ "LICENSE", "README.md", }, - .version = "0.0.1", + .version = "0.0.2", } From 2f2b8525ec6cdfa4388517dd33f28c8f6d0b53fa Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 12:31:32 +1000 Subject: [PATCH 4/7] fix --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1f38d7..972aa6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,8 @@ on: branches: - master - dev - - v1 + - "v[0-9]*" pull_request: - branches: concurrency: group: ${{ github.workflow }}-${{ github.ref }} From fd7c660b8d7238631493c6297b50ee5fa03490e1 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 13:01:12 +1000 Subject: [PATCH 5/7] Add binary scaling and expand benchmarks (v0.0.3) Implement the optional $2^{32}$ fixed-point binary scaling float-avoidance optimizations from `p99.Rust` as a comptime build option (`-Dbinary-scaling`). Update all integer-based percentile query methods (`valueAtP90`, `valueAtP95`, `valueAtP99`, etc.) to use pre-encoded multipliers when `binary_scaling` is enabled, avoiding integer division entirely. Expand and harden the benchmark suite in `benches/benchmark_histogram.zig` to make it statistically robust and compiler-safe: * Add a benchmark for the `clear()` method; * Add a benchmark for the generic `valueAtPercentile(f64)` method; * Extend the random event range to match Rust's 10-second wide-range workload (`[1, 10_000_000_000]` nanoseconds); * Apply strict `std.mem.doNotOptimizeAway` constraints on inputs and the histogram inside loops to prevent compiler dead-code elimination and loop-invariant hoisting. Bump the package version to `0.0.3` in `build.zig.zon`. Update `CHANGES.md`, `NEWS.md`, and `TODO.md` to document the release and check off the optimization task. Update `README.md` to add dedicated "Build Options" and "Benchmarks" sections with clear usage instructions. --- CHANGES.md | 10 ++ NEWS.md | 1 + README.md | 36 ++++++ TODO.md | 2 +- benches/benchmark_histogram.zig | 211 +++++++++++++++++++++++++++----- build.zig | 12 ++ build.zig.zon | 2 +- src/root.zig | 41 +++++-- 8 files changed, 271 insertions(+), 44 deletions(-) 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..2b13d1b 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))); } From 45bd140d53ecba1e4d5a35a3588e72a50d28e290 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 13:03:48 +1000 Subject: [PATCH 6/7] fmt --- benches/benchmark_histogram.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/benches/benchmark_histogram.zig b/benches/benchmark_histogram.zig index 2b13d1b..06859c9 100644 --- a/benches/benchmark_histogram.zig +++ b/benches/benchmark_histogram.zig @@ -46,7 +46,7 @@ pub fn main(init: std.process.Init) !void { 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} (across {d} trials)\n", .{count * trials, trials}); + 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}); } @@ -89,7 +89,7 @@ pub fn main(init: std.process.Init) !void { 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(" Total events: {d} (across {d} trials)\n", .{ count * trials, trials }); try stdout.print(" Average/push: {d:.2} ns\n\n", .{avg_ns}); } @@ -135,7 +135,7 @@ pub fn main(init: std.process.Init) !void { 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(" Total queries: {d} (across {d} trials)\n", .{ query_count * trials, trials }); try stdout.print(" Average/query: {d:.2} ns\n\n", .{avg_ns}); } @@ -174,7 +174,7 @@ pub fn main(init: std.process.Init) !void { 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(" Total clears: {d} (across {d} trials)\n", .{ loop_count * trials, trials }); try stdout.print(" Average/clear: {d:.2} ns\n\n", .{avg_ns}); } @@ -220,7 +220,7 @@ pub fn main(init: std.process.Init) !void { 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(" Total queries: {d} (across {d} trials)\n", .{ query_count * trials, trials }); try stdout.print(" Average/query: {d:.2} ns\n\n", .{avg_ns}); } From d48bcec765104f6349cffc300676690199f4f4d0 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 26 Jul 2026 13:14:22 +1000 Subject: [PATCH 7/7] polish --- build.zig.zon | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index c337aff..0ace5c2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,12 +1,19 @@ .{ + .authors = .{ + "Matt Wilson ", + }, + .description = "A high-performance histogram implementation in Zig", .fingerprint = 0xfa31b4f0ad2d50c3, .name = .p99, .paths = .{ - "build.zig", - "build.zig.zon", - "src", + "CHANGES.md", "LICENSE", "README.md", + "benches", + "build.zig.zon", + "build.zig", + "examples", + "src", }, .version = "0.0.3", }