diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7beaa80 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ + +*.rs linguist-language=Rust diff --git a/Cargo.lock b/Cargo.lock index 3ca0112..cb2ab3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,463 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-traits" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb2de59cca22f51f3301a09c549b0b4a32f56e8a007d0d44b02a1ce42975b37" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "p99" -version = "0.0.0" +version = "0.0.1" +dependencies = [ + "criterion", + "test_help-rs", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "test_help-rs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d2df2cc6a57f53f03c47cc937df746b8f2d0f5e63b77b7d81aa30b9ceb2842" +dependencies = [ + "base-traits", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 85974cc..97d3c7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ license = "BSD-3-Clause" name = "p99" readme = "README.md" repository = "https://github.com/synesissoftware/p99.Rust" -version = "0.0.0" +version = "0.0.1" # ########################################################## @@ -33,6 +33,14 @@ version = "0.0.0" name = "p99" path = "src/lib.rs" +[[bench]] +name = "histogram" +harness = false + +[[example]] +name = "build_histogram" +path = "examples/build_histogram.rs" + # ########################################################## # Features @@ -50,5 +58,13 @@ default = [] [dev-dependencies] +# 3rd-party dependencies: +# + +criterion = { version = "0.8", default-features = false, features = [ +] } +test_help-rs = { version = "0.1", default-features = false, features = [ +] } + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 4613fcb..f777908 100644 --- a/README.md +++ b/README.md @@ -9,23 +9,67 @@ Low-cost generation of performance percentiles (p50, p90, p99, p99.9, etc.). +## Table of Contents + +- [Introduction](#introduction) +- [How It Works](#how-it-works) +- [Performance \& Trade-offs](#performance--trade-offs) + - [Performance Claims](#performance-claims) + - [Trade-offs \& Sacrifices](#trade-offs--sacrifices) +- [Installation](#installation) +- [Components](#components) + - [Constants](#constants) + - [Enumerations](#enumerations) + - [Features](#features) + - [Functions](#functions) + - [Macros](#macros) + - [Structures](#structures) + - [`Histogram`](#histogram) + - [Definition](#definition) + - [Minimal Example](#minimal-example) + - [Traits](#traits) +- [Examples](#examples) +- [Project Information](#project-information) + - [Where to get help](#where-to-get-help) + - [Contribution guidelines](#contribution-guidelines) + - [Dependencies](#dependencies) + - [Dev Dependencies](#dev-dependencies) + - [License](#license) + + ## Introduction -**p99** is a lightweight, low-overhead Rust library designed for generating real-time performance percentiles in high-frequency or latency-sensitive environments. +**p99** is a lightweight, low-overhead library designed for generating real-time performance percentiles in high-frequency or latency-sensitive environments. **p99.Rust** is the **Rust** implementation. -## Table of Contents +## How It Works -- [Introduction](#introduction) -- [Installation](#introduction) -- [Components](#components) -- [Examples](#examples) -- [Project Information](#project-information) - - [Where to get help](#where-to-get-help) - - [Contribution guidelines](#contribution-guidelines) - - [License](#license) +`Histogram` is a low-overhead, zero-allocation, fixed-size structure designed to track event durations (typically in nanoseconds) using 64 logarithmic buckets. + +* **Logarithmic Bucketing**: The bucket boundaries are spaced as powers of two: + * Bucket `0` represents `[0, 1]` nanoseconds; + * Bucket `1` represents `[2, 3]` nanoseconds; + * Bucket `2` represents `[4, 7]` nanoseconds; + * Bucket `i` represents `[2^i, 2^(i+1) - 1]` nanoseconds. +* **Branchless Indexing**: Finding the correct bucket index for an incoming duration is extremely fast and branchless. It is computed in a few CPU instructions using the CPU's leading-zeros count intrinsic (`u64::leading_zeros`). +* **Linear Interpolation**: Percentile queries iterate through the buckets to find the target rank and perform linear interpolation within the matching bucket to approximate the exact percentile duration. + + +## Performance & Trade-offs + +### Performance Claims + +* **Zero Allocation**: `Histogram` does not allocate memory on the heap during creation, event insertion, or percentile queries. It is a compact (~512-byte) structure that can reside entirely on the stack or be embedded in other structures. +* **Ultra-Low Latency Insertion**: Recording a latency measurement (`push_event_time_ns`) takes approximately **11 nanoseconds** (about 35 CPU cycles on modern hardware). +* **Blazing-Fast Queries**: Querying percentiles (such as `value_at_p99()`) takes only **11 to 17 nanoseconds**, depending on the distribution of events across the buckets. +* **Instruction-Cache Friendly**: The query methods are designed with a "thin caller / heavy worker" pattern to prevent instruction-cache bloat and maintain high CPU cache locality under real-world workloads. + +### Trade-offs & Sacrifices + +* **Logarithmic Precision**: To achieve zero allocation and constant-time operations, `Histogram` sacrifices exact precision. It does not store individual event times. Instead, values are grouped into logarithmic buckets. +* **Approximation**: Percentile values are approximated using linear interpolation within the bucket boundaries. For very large values, the bucket width is wider, which leads to a wider approximation range. However, for low-latency performance measurements where precision is needed most (the lower nanosecond ranges), the buckets are extremely narrow (e.g., 1ns, 2ns, 4ns wide), providing exceptional resolution. ## Installation @@ -66,7 +110,59 @@ No public macros are defined at this time. ### Structures -No public structures are defined at this time. +The following public structures are defined in the current version: + +#### `Histogram` + +A low-cost, zero-allocation, 64-bucket logarithmic histogram designed for recording event durations in nanoseconds and querying high-resolution percentiles. + +##### Definition + +```rust +pub struct Histogram { + // ... private fields ... +} +``` + +##### Minimal Example + +Here is a simple example of how to initialize a `Histogram`, record event times, and query percentiles: + +```rust +use p99::Histogram; +use std::time::Duration; + +fn main() { + // 1. Initialize a default histogram (zero-allocated, ~512 bytes on the stack) + let mut histogram = Histogram::default(); + + // 2. Record event times using various units + histogram.push_event_time_ns(150); // 150 ns + histogram.push_event_time_us(5); // 5 microseconds (5,000 ns) + histogram.push_event_time_ms(10); // 10 milliseconds (10,000,000 ns) + histogram.push_event_duration(Duration::from_nanos(250)); + + // 3. Retrieve basic statistics + assert_eq!(histogram.event_count(), 4); + assert_eq!(histogram.min_event_time(), Some(150)); + assert_eq!(histogram.max_event_time(), Some(10_000_000)); + assert_eq!(histogram.event_time_total(), Some(10_005_400)); + + // 4. Query percentiles + // Generic floating-point query (e.g., 90th percentile) + if let Some(p90) = histogram.value_at_percentile(90.0) { + println!("p90: {} ns", p90); + } + + // Fast, optimized integer-based percentile wrappers + if let Some(p50) = histogram.value_at_p50() { + println!("p50 (median): {} ns", p50); + } + if let Some(p99) = histogram.value_at_p99() { + println!("p99: {} ns", p99); + } +} +``` ### Traits @@ -76,7 +172,19 @@ No public traits are defined at this time. ## Examples -No example programs are provided at this time. +An example program showing `Histogram` usage is provided in [**examples/build_histogram.rs**](./examples/build_histogram.rs). + +It simulates a histogram of event times generated by `std::thread::sleep` delays under a custom PRNG. + +The number of iterations can be configured via the `P99_TRIES` environment variable: + +```bash +# Run with the default of 100 tries +cargo run --example build_histogram + +# Run with 1000 tries +P99_TRIES=1000 cargo run --example build_histogram +``` ## Project Information @@ -91,6 +199,18 @@ No example programs are provided at this time. Defect reports, feature requests, and pull requests are welcome on https://github.com/synesissoftware/p99.Rust. +### Dependencies + +**p99.Rust** has no (non-development) dependencies. + +#### Dev Dependencies + +Crates upon which **p99.Rust** has development dependencies: + +* [**criterion**](https://github.com/bheisler/criterion.rs); +* [**test_help-rs**](https://github.com/synesissoftware/test_help-rs); + + ### License **p99.Rust** 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..5b7f408 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# p99.Rust + + +## Table of Contents + +- [TODOs](#todos) + + +## TODOs + +- [ ] `Debug` form; +- [ ] binary scaling; + diff --git a/benches/histogram.rs b/benches/histogram.rs new file mode 100644 index 0000000..282300e --- /dev/null +++ b/benches/histogram.rs @@ -0,0 +1,160 @@ +// benches/histogram.rs : benchmarking `Histogram` + +#![allow(non_snake_case)] + +use p99::Histogram; + +#[rustfmt::skip] +use criterion::{ + BatchSize, + Criterion, + criterion_group, + criterion_main, +}; + +#[rustfmt::skip] +use std::{ + hint as std_hint, +}; + +// Helper functions + +fn build_sequential_histogram() -> Histogram { + let mut h = Histogram::default(); + for i in 1..=100_000 { + let _ = h.push_event_time_ns(i * 10); + } + + h +} + +fn build_wide_range_histogram() -> Histogram { + let mut h = Histogram::default(); + let mut state = 12_345u64; + for _ in 1..=100_000 { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + let val = (state % 10_000_000_000) + 1; + let _ = h.push_event_time_ns(val); + } + + h +} + +fn bench_percentile_comparison( + c: &mut Criterion, + h: &Histogram, + percentile_name: &str, + workload_name: &str, + float_val: f64, + int_method: F, +) where + F: Fn(&Histogram) -> Option + Copy, +{ + let float_str = if float_val.fract() == 0.0 { + format!("{:.1}", float_val) + } else { + format!("{}", float_val) + }; + + let float_id = format!("`Histogram::value_at_percentile({})` [{}]", float_str, workload_name); + c.bench_function(&float_id, |b| { + b.iter(|| { + std_hint::black_box(h.value_at_percentile(std_hint::black_box(float_val))); + }) + }); + + let int_id = format!("`Histogram::value_at_p{}()` [{}]", percentile_name, workload_name); + c.bench_function(&int_id, |b| { + b.iter(|| { + std_hint::black_box(int_method(std_hint::black_box(h))); + }) + }); +} + +// Benchmarks + +fn BENCHMARK_bucket_index_SMALL(c: &mut Criterion) { + let id = format!("`Histogram::bucket_index(1)`"); + + c.bench_function(&id, |b| { + b.iter(|| { + let idx = Histogram::bucket_index(std_hint::black_box(1)); + + let _ = std_hint::black_box(idx); + }) + }); +} + +fn BENCHMARK_bucket_index_LARGE(c: &mut Criterion) { + let id = format!("`Histogram::bucket_index(u64::MAX)`"); + + c.bench_function(&id, |b| { + b.iter(|| { + let idx = Histogram::bucket_index(std_hint::black_box(u64::MAX)); + + let _ = std_hint::black_box(idx); + }) + }); +} + +fn BENCHMARK_push_event_time_ns(c: &mut Criterion) { + let id = format!("`Histogram::push_event_time_ns()`"); + + c.bench_function(&id, |b| { + b.iter_batched_ref( + || Histogram::default(), + |h| { + std_hint::black_box(h.push_event_time_ns(std_hint::black_box(12_345))); + }, + BatchSize::SmallInput, + ) + }); +} + +fn BENCHMARK_clear(c: &mut Criterion) { + let id = format!("`Histogram::clear()`"); + + c.bench_function(&id, |b| { + b.iter_batched_ref( + || { + let mut h = Histogram::default(); + h.push_event_time_ns(100); + h.push_event_time_ns(200); + + h + }, + |h| { + std_hint::black_box(h.clear()); + }, + BatchSize::SmallInput, + ) + }); +} + +fn BENCHMARK_percentile_queries(c: &mut Criterion) { + // 1. Benchmark under dense sequential 100k events + let seq_h = build_sequential_histogram(); + bench_percentile_comparison(c, &seq_h, "99", "100k events", 99.0, |h| h.value_at_p99()); + + // 2. Benchmark under sparse wide-range 100k events + let wide_h = build_wide_range_histogram(); + bench_percentile_comparison(c, &wide_h, "50", "100k wide-range events", 50.0, |h| h.value_at_p50()); + bench_percentile_comparison(c, &wide_h, "75", "100k wide-range events", 75.0, |h| h.value_at_p75()); + bench_percentile_comparison(c, &wide_h, "90", "100k wide-range events", 90.0, |h| h.value_at_p90()); + bench_percentile_comparison(c, &wide_h, "99", "100k wide-range events", 99.0, |h| h.value_at_p99()); + bench_percentile_comparison(c, &wide_h, "99_99", "100k wide-range events", 99.99, |h| { + h.value_at_p99_99() + }); +} + +// Macros + +criterion_group!( + benches, + BENCHMARK_bucket_index_SMALL, + BENCHMARK_bucket_index_LARGE, + BENCHMARK_push_event_time_ns, + BENCHMARK_clear, + BENCHMARK_percentile_queries, +); +criterion_main!(benches); diff --git a/examples/build_histogram.rs b/examples/build_histogram.rs new file mode 100644 index 0000000..f02eeec --- /dev/null +++ b/examples/build_histogram.rs @@ -0,0 +1,78 @@ +// examples/build_histogram.rs : example program showing `Histogram` + +use p99::Histogram; + +#[rustfmt::skip] +use std::{ + env as std_env, + thread as std_thread, + time as std_time, +}; + +struct SimpleRng { + state: u64, +} + +// API functions + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self { state: seed } + } +} + +// Mutating methods + +impl SimpleRng { + fn next(&mut self) -> u64 { + self.state = self.state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + + self.state + } +} + +// Helper functions + +fn main() { + let tries = match std_env::var("P99_TRIES") { + Ok(val) => match val.parse::() { + Ok(num) => num, + Err(_) => { + eprintln!("Warning: failed to parse P99_TRIES value '{}', defaulting to 100", val); + + 100 + }, + }, + Err(_) => 100, + }; + + println!("Running Histogram example with {} tries...", tries); + + let mut histogram = Histogram::default(); + let mut rng = SimpleRng::new(12_345); + + for _ in 0..tries { + // Generate a random delay from 1 to 500 microseconds. + let delay_us = (rng.next() % 1_000) + 1; + let start = std_time::Instant::now(); + + std_thread::sleep(std_time::Duration::from_micros(delay_us)); + + let elapsed = start.elapsed(); + histogram.push_event_duration(elapsed); + } + + println!("\nHistogram printed via `{{:#?}}` format:\n"); + println!("{:#?}", histogram); + + println!("\nPercentiles (approximated):"); + println!(" p50 (f64): {:?} ns", histogram.value_at_percentile(50.0)); + println!(" p50 (integer): {:?} ns", histogram.value_at_p50()); + println!(" p75 (integer): {:?} ns", histogram.value_at_p75()); + println!(" p90 (integer): {:?} ns", histogram.value_at_p90()); + println!(" p95 (integer): {:?} ns", histogram.value_at_p95()); + println!(" p99 (integer): {:?} ns", histogram.value_at_p99()); + println!(" p99.5 (integer): {:?} ns", histogram.value_at_p99_5()); + println!(" p99.9 (integer): {:?} ns", histogram.value_at_p99_9()); + println!(" p99.99 (integer): {:?} ns", histogram.value_at_p99_99()); +} diff --git a/src/lib.rs b/src/lib.rs index 8b13789..64844e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,910 @@ +// src/lib.rs : `Histogram` +use std::time as std_time; + +/// Low-cost performance percentile histogram using 64-buckets. +/// +/// # Note: +/// This histogram 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. +#[derive(Clone)] +#[derive(Debug)] +pub struct Histogram { + event_count: usize, + + event_time_total: u64, + has_overflowed: bool, + + min_event_time: Option, + max_event_time: Option, + + buckets: [u64; 64], +} + +// API functions + +impl Histogram { + // +} + +// Mutating methods + +impl Histogram { + /// Clears the instance, resetting all values to the equivalent of a + /// newly constructed instance. + pub fn clear(&mut self) { + *self = Default::default(); + } + + /// Pushes an event with the given [`std_time::Duration`]. + /// + /// # Note: + /// The value obtained from `Duration#as_nanos()` is truncated to + /// `u64`. + pub fn push_event_duration( + &mut self, + duration: std_time::Duration, + ) -> bool { + return self.push_event_time_ns(duration.as_nanos() as u64); + } + + /// Pushes an event with the given number of nanoseconds. + pub fn push_event_time_ns( + &mut self, + time_in_ns: u64, + ) -> bool { + if self.try_add_ns_to_total_and_update_minmax_and_count_(time_in_ns) { + self.event_count += 1; + + let bucket = Self::bucket_index(time_in_ns); + self.buckets[bucket] += 1; + + true + } else { + false + } + } + + /// Pushes an event with the given number of microseconds. + pub fn push_event_time_us( + &mut self, + time_in_us: u64, + ) -> bool { + if let Some(time_in_ns) = time_in_us.checked_mul(1_000) { + let r = self.push_event_time_ns(time_in_ns); + + r + } else { + self.has_overflowed = true; + + false + } + } + + /// Pushes an event with the given number of milliseconds. + pub fn push_event_time_ms( + &mut self, + time_in_ms: u64, + ) -> bool { + if let Some(time_in_ns) = time_in_ms.checked_mul(1_000_000) { + let r = self.push_event_time_ns(time_in_ns); + + r + } else { + self.has_overflowed = true; + + false + } + } + + /// Pushes an event with the given number of seconds. + pub fn push_event_time_s( + &mut self, + time_in_s: u64, + ) -> bool { + if let Some(time_in_ns) = time_in_s.checked_mul(1_000_000_000) { + let r = self.push_event_time_ns(time_in_ns); + + r + } else { + self.has_overflowed = true; + + false + } + } +} + +// Non-mutating methods + +impl Histogram { + /// Returns the count of events in a specific bucket. + pub fn bucket_value( + &self, + index: usize, + ) -> Option { + if index < 64 { + Some(self.buckets[index]) + } else { + None + } + } + + /// Returns a reference to all 64 buckets. + pub fn buckets(&self) -> &[u64; 64] { + &self.buckets + } + + /// Number of events counted. + pub fn event_count(&self) -> usize { + self.event_count + } + + /// Returns the total event time in nanoseconds, if no overflow occurred. + pub fn event_time_total(&self) -> Option { + if self.has_overflowed { + None + } else { + Some(self.event_time_total) + } + } + + /// Returns the total event time in nanoseconds, regardless of whether + /// overflow has occurred. + pub fn event_time_total_raw(&self) -> u64 { + self.event_time_total + } + + /// Indicates whether overflow has occurred. + pub fn has_overflowed(&self) -> bool { + self.has_overflowed + } + + /// Returns the minimum event time observed, if any. + pub fn min_event_time(&self) -> Option { + self.min_event_time + } + + /// Returns the maximum event time observed, if any. + pub fn max_event_time(&self) -> Option { + self.max_event_time + } + + /// Returns the approximated duration (in nanoseconds) at the given + /// percentile. + /// + /// # Parameters + /// - `percentile`: A float value representing the desired percentile + /// (e.g., `50.0` for p50, `99.0` for p99); value is clamped to + /// the range `[0.0, 100.0]`; + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + pub fn value_at_percentile( + &self, + percentile: f64, + ) -> Option { + if self.event_count == 0 { + return None; + } + + let p = percentile.clamp(0.0, 100.0); + + if p <= 0.0 { + let r = self.min_event_time; + + return r; + } + + if p >= 100.0 { + let r = self.max_event_time; + + return r; + } + + let target_rank = self.event_count as f64 * (p / 100.0); + let mut accumulated = 0u64; + + // Iterate forwards because spatial clustering of latency events means + // that active events cluster heavily in the lower-indexed buckets. + // Higher-indexed buckets (e.g., above 30) are almost always empty + // in high-performance loops. Iterating forwards allows the loop + // to terminate much earlier (typically under 20 iterations). + for i in 0..64 { + let count = self.buckets[i]; + + if count > 0 { + let prev_accumulated = accumulated; + accumulated += count; + + if accumulated as f64 >= target_rank { + let (lower, upper) = Self::bucket_range(i).unwrap_or((0, u64::MAX)); + + let target_offset = target_rank - prev_accumulated as f64; + let range_width = if i == 63 { + (u64::MAX - lower) as f64 + } else { + (upper - lower) as f64 + }; + + let fraction = target_offset / count as f64; + let interpolated = lower as f64 + (range_width * fraction); + let mut value = interpolated.round() as u64; + + if let Some(min) = self.min_event_time { + if value < min { + value = min; + } + } + + if let Some(max) = self.max_event_time { + if value > max { + value = max; + } + } + + let r = Some(value); + + return r; + } + } + } + + let r = self.max_event_time; + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 50th + /// percentile (p50). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p50(&self) -> Option { + let target_rank = (self.event_count as u128 * 1) / 2; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 75th + /// percentile (p75). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p75(&self) -> Option { + let target_rank = (self.event_count as u128 * 3) / 4; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 90th + /// percentile (p90). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p90(&self) -> Option { + let target_rank = (self.event_count as u128 * 90) / 100; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 95th + /// percentile (p95). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p95(&self) -> Option { + let target_rank = (self.event_count as u128 * 95) / 100; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 99th + /// percentile (p99). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p99(&self) -> Option { + let target_rank = (self.event_count as u128 * 99) / 100; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 99.5th + /// percentile (p99.5). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p99_5(&self) -> Option { + let target_rank = (self.event_count as u128 * 995) / 1_000; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 99.9th + /// percentile (p99.9). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p99_9(&self) -> Option { + let target_rank = (self.event_count as u128 * 999) / 1_000; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 99.99th + /// percentile (p99.99). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p99_99(&self) -> Option { + let target_rank = (self.event_count as u128 * 9_999) / 10_000; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 99.999th + /// percentile (p99.999). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p99_999(&self) -> Option { + let target_rank = (self.event_count as u128 * 99_999) / 100_000; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } + + /// Returns the approximated duration (in nanoseconds) at the 99.9999th + /// percentile (p99.9999). + /// + /// # Return + /// Returns `Some(value_in_ns)` if the histogram contains one or more + /// events; otherwise, returns `None`. + #[inline(always)] + pub fn value_at_p99_999_9(&self) -> Option { + let target_rank = (self.event_count as u128 * 999_999) / 1_000_000; + let r = self.value_at_target_rank_impl(target_rank as u64); + + r + } +} + +// Trait implementations + +impl Default for Histogram { + fn default() -> Self { + Self { + event_count: 0, + event_time_total: 0, + has_overflowed: false, + min_event_time: None, + max_event_time: None, + buckets: [0; 64], + } + } +} + +// Implementation + +impl Histogram { + /// 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 + /// `leading_zeros()` instruction, avoiding loop and branching logic. + #[doc(hidden)] + #[inline] + pub fn bucket_index(time_in_ns: u64) -> usize { + if time_in_ns <= 1 { + 0 + } else { + (64 - time_in_ns.leading_zeros() - 1) as usize + } + } + + /// 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]`; + #[doc(hidden)] + pub fn bucket_range(index: usize) -> Option<(u64, u64)> { + if index >= 64 { + None + } else if index == 0 { + Some((0, 1)) + } else { + let lower = 1u64 << index; + let upper = if index == 63 { + u64::MAX + } else { + (1u64 << (index + 1)) - 1 + }; + + Some((lower, upper)) + } + } + + fn try_add_ns_to_total_and_update_minmax_and_count_( + &mut self, + time_in_ns: u64, + ) -> bool { + if self.has_overflowed { + return false; + } + + match self.event_time_total.checked_add(time_in_ns) { + Some(new_total) => { + self.event_time_total = new_total; + + match self.min_event_time { + Some(min_event_time) => { + if time_in_ns < min_event_time { + self.min_event_time = Some(time_in_ns); + } + }, + None => { + self.min_event_time = Some(time_in_ns); + }, + } + + match self.max_event_time { + Some(max_event_time) => { + if time_in_ns > max_event_time { + self.max_event_time = Some(time_in_ns); + } + }, + None => { + self.max_event_time = Some(time_in_ns); + }, + } + + true + }, + None => { + self.has_overflowed = true; + + false + }, + } + } + + fn value_at_target_rank_impl( + &self, + target_rank: u64, + ) -> Option { + if self.event_count == 0 { + return None; + } + + let mut accumulated = 0u64; + + // Iterate forwards because spatial clustering of latency events means + // that active events cluster heavily in the lower-indexed buckets. + // Higher-indexed buckets (e.g., above 30) are almost always empty + // in high-performance loops. Iterating forwards allows the loop + // to terminate much earlier (typically under 20 iterations). + for i in 0..64 { + let count = self.buckets[i]; + + if count > 0 { + let prev_accumulated = accumulated; + accumulated += count; + + if accumulated >= target_rank { + let (lower, upper) = Self::bucket_range(i).unwrap_or((0, u64::MAX)); + + let target_offset = target_rank - prev_accumulated; + + let interpolated = if target_offset == 0 { + lower + } else { + let range_width = if i == 63 { u64::MAX - lower } else { upper - lower }; + + if let Some(prod) = range_width.checked_mul(target_offset) { + lower + prod / count + } else { + let val_u128 = + lower as u128 + (range_width as u128 * target_offset as u128) / count as u128; + + val_u128 as u64 + } + }; + + let mut value = interpolated; + + if let Some(min) = self.min_event_time { + if value < min { + value = min; + } + } + + if let Some(max) = self.max_event_time { + if value > max { + value = max; + } + } + + let r = Some(value); + + return r; + } + } + } + + let r = self.max_event_time; + + r + } +} + +// Tests + +#[cfg(test)] +mod test_helpers { + #![allow(non_snake_case)] + #![allow(unused)] +} + +#[cfg(test)] +mod tests { + #![allow(non_snake_case)] + #![cfg_attr(debug_assertions, allow(unused_imports))] + + use super::Histogram; + + use test_helpers::{ + assert_scalar_eq_approx, + multiplier, + }; + + use std::time as std_time; + + #[test] + fn TEST_Histogram_Default() { + let h = Histogram::default(); + + assert_eq!(0, h.event_count()); + assert_eq!(Some(0), h.event_time_total()); + assert_eq!(0, h.event_time_total_raw()); + assert!(!h.has_overflowed()); + assert_eq!(None, h.min_event_time()); + assert_eq!(None, h.max_event_time()); + + for i in 0..64 { + assert_eq!(0, h.buckets()[i]); + assert_eq!(Some(0), h.bucket_value(i)); + } + + assert_eq!(None, h.bucket_value(64)); + } + + #[test] + fn TEST_Histogram_bucket_index() { + assert_eq!(0, Histogram::bucket_index(0)); + assert_eq!(0, Histogram::bucket_index(1)); + + assert_eq!(1, Histogram::bucket_index(2)); + assert_eq!(1, Histogram::bucket_index(3)); + + assert_eq!(2, Histogram::bucket_index(4)); + assert_eq!(2, Histogram::bucket_index(7)); + + assert_eq!(3, Histogram::bucket_index(8)); + assert_eq!(3, Histogram::bucket_index(15)); + + assert_eq!(4, Histogram::bucket_index(16)); + assert_eq!(4, Histogram::bucket_index(31)); + + assert_eq!(10, Histogram::bucket_index(1024)); + assert_eq!(10, Histogram::bucket_index(2047)); + + assert_eq!(63, Histogram::bucket_index(1u64 << 63)); + assert_eq!(63, Histogram::bucket_index(u64::MAX)); + } + + #[test] + fn TEST_Histogram_bucket_range() { + assert_eq!(Some((0, 1)), Histogram::bucket_range(0)); + assert_eq!(Some((2, 3)), Histogram::bucket_range(1)); + assert_eq!(Some((4, 7)), Histogram::bucket_range(2)); + assert_eq!(Some((8, 15)), Histogram::bucket_range(3)); + assert_eq!(Some((16, 31)), Histogram::bucket_range(4)); + assert_eq!(Some((1024, 2047)), Histogram::bucket_range(10)); + assert_eq!(Some((1u64 << 63, u64::MAX)), Histogram::bucket_range(63)); + + assert_eq!(None, Histogram::bucket_range(64)); + } + + #[test] + fn TEST_Histogram_PUSH_EVENTS() { + let mut h = Histogram::default(); + + assert!(h.push_event_time_ns(1)); + assert!(h.push_event_time_ns(3)); + assert!(h.push_event_time_us(10)); + assert!(h.push_event_time_ms(5)); + assert!(h.push_event_time_s(2)); + assert!(h.push_event_duration(std_time::Duration::from_nanos(100))); + + assert_eq!(6, h.event_count()); + assert!(!h.has_overflowed()); + assert_eq!(Some(1), h.min_event_time()); + assert_eq!(Some(2_000_000_000), h.max_event_time()); + assert_eq!(Some(2_005_010_104), h.event_time_total()); + + assert_eq!(1, h.buckets()[0]); + assert_eq!(1, h.buckets()[1]); + assert_eq!(1, h.buckets()[6]); + assert_eq!(1, h.buckets()[13]); + assert_eq!(1, h.buckets()[22]); + assert_eq!(1, h.buckets()[30]); + + h.clear(); + + assert_eq!(0, h.event_count()); + assert_eq!(Some(0), h.event_time_total()); + } + + #[test] + fn TEST_Histogram_OVERFLOW() { + let mut h = Histogram::default(); + assert!(h.push_event_time_ns(u64::MAX)); + assert_eq!(Some(u64::MAX), h.event_time_total()); + assert!(!h.has_overflowed()); + + assert!(!h.push_event_time_ns(1)); + assert!(h.has_overflowed()); + assert_eq!(None, h.event_time_total()); + assert_eq!(u64::MAX, h.event_time_total_raw()); + } + + #[test] + fn TEST_Histogram_PERCENTILES_EMPTY() { + let h = Histogram::default(); + + assert_eq!(None, h.value_at_percentile(50.0)); + assert_eq!(None, h.value_at_p50()); + assert_eq!(None, h.value_at_p99()); + } + + #[test] + fn TEST_Histogram_PERCENTILES_SINGLE_EVENT() { + let mut h = Histogram::default(); + assert!(h.push_event_time_ns(100)); + + assert_eq!(Some(100), h.value_at_percentile(0.0)); + assert_eq!(Some(100), h.value_at_percentile(50.0)); + assert_eq!(Some(100), h.value_at_percentile(99.0)); + assert_eq!(Some(100), h.value_at_percentile(100.0)); + + assert_eq!(Some(100), h.value_at_p50()); + assert_eq!(Some(100), h.value_at_p90()); + assert_eq!(Some(100), h.value_at_p99()); + assert_eq!(Some(100), h.value_at_p99_999_9()); + } + + #[test] + fn TEST_Histogram_PERCENTILES_INTERPOLATION() { + let mut h = Histogram::default(); + assert!(h.push_event_time_ns(100)); // Bucket 6 [64, 127] + assert!(h.push_event_time_ns(200)); // Bucket 7 [128, 255] + + let p50 = h.value_at_percentile(50.0); + let p99 = h.value_at_percentile(99.0); + + assert!(p50.is_some()); + assert!(p99.is_some()); + + assert!(p50.unwrap() >= 100 && p50.unwrap() <= 200); + assert!(p99.unwrap() >= 100 && p99.unwrap() <= 200); + + assert_eq!(Some(100), h.value_at_percentile(0.0)); + assert_eq!(Some(200), h.value_at_percentile(100.0)); + + // Int-based equivalents + assert!(h.value_at_p50().unwrap() >= 100); + assert!(h.value_at_p99().unwrap() <= 200); + } + + #[test] + fn TEST_Histogram_PERCENTILES_WIDE_RANGE() { + let mut h = Histogram::default(); + + // Push events spanning a vast range of magnitudes + let values = [ + 1, // 1 ns (Bucket 0) + 10, // 10 ns (Bucket 3) + 100, // 100 ns (Bucket 6) + 1_000, // 1 us (Bucket 9) + 10_000, // 10 us (Bucket 13) + 100_000, // 100 us (Bucket 16) + 1_000_000, // 1 ms (Bucket 19) + 10_000_000, // 10 ms (Bucket 23) + 100_000_000, // 100 ms (Bucket 26) + 1_000_000_000, // 1 s (Bucket 29) + 10_000_000_000, // 10 s (Bucket 33) + ]; + + for &v in &values { + assert!(h.push_event_time_ns(v)); + } + + assert_eq!(values.len(), h.event_count()); + assert_eq!(Some(1), h.min_event_time()); + assert_eq!(Some(10_000_000_000), h.max_event_time()); + + // Check that percentiles are strictly monotonic + let p50 = h.value_at_p50().unwrap(); + let p75 = h.value_at_p75().unwrap(); + let p90 = h.value_at_p90().unwrap(); + let p95 = h.value_at_p95().unwrap(); + let p99 = h.value_at_p99().unwrap(); + let p99_5 = h.value_at_p99_5().unwrap(); + let p99_9 = h.value_at_p99_9().unwrap(); + let p99_99 = h.value_at_p99_99().unwrap(); + let p99_999 = h.value_at_p99_999().unwrap(); + let p99_999_9 = h.value_at_p99_999_9().unwrap(); + + assert!(p50 <= p75); + assert!(p75 <= p90); + assert!(p90 <= p95); + assert!(p95 <= p99); + assert!(p99 <= p99_5); + assert!(p99_5 <= p99_9); + assert!(p99_9 <= p99_99); + assert!(p99_99 <= p99_999); + assert!(p99_999 <= p99_999_9); + + // Verify bounds + assert!(p50 >= 1); + assert!(p99_999_9 <= 10_000_000_000); + } + + #[test] + fn TEST_Histogram_PERCENTILES_MANY_EVENTS() { + let mut h = Histogram::default(); + let count = 100_000; + + // Push 100,000 linear events from 1 to 100,000 ns + for i in 1..=count { + assert!(h.push_event_time_ns(i as u64)); + } + + assert_eq!(count, h.event_count()); + assert_eq!(Some(1), h.min_event_time()); + assert_eq!(Some(count as u64), h.max_event_time()); + + // Check approximated percentiles against theoretical values. + // Because of the logarithmic bucket spacing, larger values have wider buckets, + // so the approximation error will be larger for higher percentiles, but they + // should still be reasonably close. + let p50 = h.value_at_p50().unwrap(); + let p90 = h.value_at_p90().unwrap(); + let p99 = h.value_at_p99().unwrap(); + let p99_9 = h.value_at_p99_9().unwrap(); + + // p50 theoretical = 50,000. Bucket 15 is [32768, 65535] (width 32768). + // Since bucket 15 is fully populated, the linear interpolation is extremely accurate. + assert_eq!(50_000, p50, "p50 was {}, expected exactly 50000", p50); + + // p90 theoretical = 90,000. Bucket 16 is [65536, 131071] (width 65535). + // Since we only populated up to 100,000, bucket 16 is partially populated. + // The linear interpolation assumes events are spread up to 131,071, which + // yields an unclamped estimate of ~112,055. This is correctly clamped + // to the actual maximum seen event time of 100,000. + assert_eq!(100_000, p90, "p90 was {}, expected clamped to 100000", p90); + + // p99 theoretical = 99,000. Also in bucket 16, also clamped to max. + assert_eq!(100_000, p99, "p99 was {}, expected clamped to 100000", p99); + + // p99.9 theoretical = 99,900. Also in bucket 16, also clamped to max. + assert_eq!(100_000, p99_9, "p99.9 was {}, expected clamped to 100000", p99_9); + + // Check monotonicity of all integer percentiles + let p75 = h.value_at_p75().unwrap(); + let p95 = h.value_at_p95().unwrap(); + let p99_5 = h.value_at_p99_5().unwrap(); + let p99_99 = h.value_at_p99_99().unwrap(); + let p99_999 = h.value_at_p99_999().unwrap(); + let p99_999_9 = h.value_at_p99_999_9().unwrap(); + + assert!(p50 <= p75); + assert!(p75 <= p90); + assert!(p90 <= p95); + assert!(p95 <= p99); + assert!(p99 <= p99_5); + assert!(p99_5 <= p99_9); + assert!(p99_9 <= p99_99); + assert!(p99_99 <= p99_999); + assert!(p99_999 <= p99_999_9); + } + + #[test] + fn TEST_Histogram_COMPARE_FLOAT_AND_INT_PERCENTILES() { + let mut h = Histogram::default(); + + // Push some values representing a realistic latency distribution + for i in 1..=10_000 { + let val = (i * i) % 1_000_000; + assert!(h.push_event_time_ns(val as u64)); + } + + // Compare float vs int percentiles + let float_p50 = h.value_at_percentile(50.0).unwrap(); + let int_p50 = h.value_at_p50().unwrap(); + assert_scalar_eq_approx!(float_p50 as f64, int_p50 as f64, multiplier(0.01)); + + let float_p75 = h.value_at_percentile(75.0).unwrap(); + let int_p75 = h.value_at_p75().unwrap(); + assert_scalar_eq_approx!(float_p75 as f64, int_p75 as f64, multiplier(0.01)); + + let float_p90 = h.value_at_percentile(90.0).unwrap(); + let int_p90 = h.value_at_p90().unwrap(); + assert_scalar_eq_approx!(float_p90 as f64, int_p90 as f64, multiplier(0.01)); + + let float_p95 = h.value_at_percentile(95.0).unwrap(); + let int_p95 = h.value_at_p95().unwrap(); + assert_scalar_eq_approx!(float_p95 as f64, int_p95 as f64, multiplier(0.01)); + + let float_p99 = h.value_at_percentile(99.0).unwrap(); + let int_p99 = h.value_at_p99().unwrap(); + assert_scalar_eq_approx!(float_p99 as f64, int_p99 as f64, multiplier(0.01)); + + let float_p99_5 = h.value_at_percentile(99.5).unwrap(); + let int_p99_5 = h.value_at_p99_5().unwrap(); + assert_scalar_eq_approx!(float_p99_5 as f64, int_p99_5 as f64, multiplier(0.01)); + + let float_p99_9 = h.value_at_percentile(99.9).unwrap(); + let int_p99_9 = h.value_at_p99_9().unwrap(); + assert_scalar_eq_approx!(float_p99_9 as f64, int_p99_9 as f64, multiplier(0.01)); + + let float_p99_99 = h.value_at_percentile(99.99).unwrap(); + let int_p99_99 = h.value_at_p99_99().unwrap(); + assert_scalar_eq_approx!(float_p99_99 as f64, int_p99_99 as f64, multiplier(0.01)); + + let float_p99_999 = h.value_at_percentile(99.999).unwrap(); + let int_p99_999 = h.value_at_p99_999().unwrap(); + assert_scalar_eq_approx!(float_p99_999 as f64, int_p99_999 as f64, multiplier(0.01)); + + let float_p99_999_9 = h.value_at_percentile(99.9999).unwrap(); + let int_p99_999_9 = h.value_at_p99_999_9().unwrap(); + assert_scalar_eq_approx!(float_p99_999_9 as f64, int_p99_999_9 as f64, multiplier(0.01)); + } +}