Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
java-version: ["17", "21"]
steps:
- uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ matrix.java-version }}
cache: maven
- name: Build and test
run: mvn -B --no-transfer-progress verify
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,18 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*

# Maven
target/

# IDE
.idea/
*.iml
.vscode/
.classpath
.project
.settings/

# Benchmarks
benchmarks/dependency-reduced-pom.xml
benchmarks/results.json
160 changes: 160 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# h2histogram-java

[![CI](https://github.com/iopsystems/h2histogram-java/actions/workflows/ci.yml/badge.svg)](https://github.com/iopsystems/h2histogram-java/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

A pure-Java implementation of the [iopsystems h2 histogram](https://github.com/iopsystems/histogram).

`h2histogram` produces histograms with **byte-for-byte identical bucketing** to the
Rust `histogram` crate, so histograms recorded here can be consumed by
[Rezolus](https://github.com/iopsystems/rezolus) — and, conversely, you can read an
h2histogram produced by Rezolus (or the [Python](https://github.com/iopsystems/h2histogram-py)
and [Go](https://github.com/iopsystems/h2histogram-go) implementations) and analyze
it on the JVM. Values are carried in Java `long`s with unsigned semantics
(`Long.compareUnsigned` and friends), so the full `u64` value range is supported,
exactly like the Rust crate.

## What is an h2 histogram?

An h2 histogram quantizes values into buckets using two parameters:

- **`groupingPower`** — the number of buckets spanning each power of two. It sets
the relative error to `2^-groupingPower` (e.g. `groupingPower=7` → ~0.78% error).
- **`maxValuePower`** — the largest representable value is `2^maxValuePower - 1`.

Values below `2^(groupingPower+1)` are stored **exactly** (linear buckets of width 1);
larger values fall into logarithmic buckets. This gives HDR-histogram-like guarantees
with a simpler, faster bucket index computation. Rezolus records histograms with
`groupingPower=3` and `maxValuePower=64`.

## Install

Maven:

```xml
<dependency>
<groupId>com.iopsystems</groupId>
<artifactId>h2histogram</artifactId>
<version>0.1.0</version>
</dependency>
```

Gradle:

```kotlin
implementation("com.iopsystems:h2histogram:0.1.0")
```

The library requires Java 17 or later and has no runtime dependencies.

## Quick start

```java
import com.iopsystems.h2histogram.Bucket;
import com.iopsystems.h2histogram.Histogram;
import com.iopsystems.h2histogram.SparseHistogram;

Histogram h = new Histogram(7, 64); // groupingPower, maxValuePower

h.increment(42);
h.record(1000, 5); // value, count
h.recordMany(new long[] {12, 15, 900}); // bulk

System.out.println(h.totalCount()); // 9

Bucket p99 = h.percentile(0.99).orElseThrow(); // Optional.empty() if empty
System.out.println(p99.start() + " " + p99.end() + " " + p99.midpoint());

// Combine / reduce
Histogram merged = h.merge(other); // element-wise sum
Histogram coarse = h.downsample(4); // fewer buckets, higher error, same total count
SparseHistogram sparse = h.toSparse(); // columnar (index, count) form for storage
```

### Fast repeated quantile queries

For a snapshot you'll query many times, convert to a `CumulativeHistogram`
(the crate's `CumulativeROHistogram`). It stores non-zero buckets with
**cumulative** counts, so percentiles are answered with a binary search, and it
precomputes a midpoint-estimated mean:

```java
import com.iopsystems.h2histogram.BucketWithQuantiles;
import com.iopsystems.h2histogram.CumulativeHistogram;

CumulativeHistogram c = h.toCumulative(); // read-only; also SparseHistogram.toCumulative()
Bucket b = c.percentile(0.99).orElseThrow(); // O(log n) binary search (individual count)
double mean = c.mean().orElseThrow(); // midpoint-estimated mean, computed once
c.bucketQuantileRange(0); // quantile fractions of a stored bucket
for (BucketWithQuantiles bq : c.bucketsWithQuantiles()) {
// each non-zero bucket with its quantile span
}
```

## API overview

| Type | Purpose |
|------|---------|
| `Config` | Bucketing parameters; `valueToIndex`, `indexToLowerBound`/`indexToUpperBound`, `totalBuckets`, `error` |
| `Histogram` | Dense histogram; `increment`, `record`, `recordMany`, `percentile(s)`, `merge`, `subtract`, `downsample`, `toSparse`, `toCumulative`, `fromBuckets` |
| `SparseHistogram` | Columnar `(index, count)` form; `fromHistogram`, `fromParts`, `toDense`, `toCumulative` |
| `CumulativeHistogram` | Read-only cumulative form (crate's `CumulativeROHistogram`); binary-search `percentile(s)`, `mean`, `bucketQuantileRange`, `bucketsWithQuantiles` |
| `Bucket` | A bucket's `count` and inclusive `[start, end]` range, plus `midpoint`/`width` |

## Unsigned values

All values and counts are unsigned 64-bit integers (`u64`) carried in Java
`long`s. A negative `long` represents a value above `Long.MAX_VALUE`; use
`Long.toUnsignedString`, `Long.parseUnsignedLong`, and `Long.compareUnsigned`
when working near the top of the range. `Bucket.midpoint()` and
`CumulativeHistogram.mean()` already account for this and return correct
`double` estimates across the full range.

## Compatibility across implementations

The same bucketing is implemented in:

- [Rust](https://github.com/iopsystems/histogram) — the canonical implementation
- [Python](https://github.com/iopsystems/h2histogram-py)
- [Go](https://github.com/iopsystems/h2histogram-go)
- [JavaScript](https://github.com/iopsystems/h2histogram-js) (limited to `maxValuePower <= 53`,
since JS numbers are 64-bit floats)
- Java (this repository) — full `u64` range

Because the bucket indices are identical, a `(bucket_indices, bucket_counts)`
pair produced by any of these can be loaded via `SparseHistogram.fromParts` /
`CumulativeHistogram.fromParts` and analyzed here.

## Correctness

The bucketing math is verified against the exact assertions from the Rust crate's
own unit tests (`src/config.rs`), so the bucketing is guaranteed bit-identical.
Run `mvn test` to see for yourself.

## Building

```bash
mvn verify
```

## Releasing

Releases are published to Maven Central. To cut a release:

1. **Land your changes on `main`** via a pull request.
2. **Set the release version** (drop the `-SNAPSHOT` suffix) in
[`pom.xml`](pom.xml), e.g. `0.1.0`, and merge that change.
3. **Tag and push** a `vX.Y.Z` tag on `main`:

```bash
git checkout main && git pull
git tag v0.1.0
git push origin v0.1.0
```

4. **Deploy** with `mvn -Prelease deploy` using Sonatype (Maven Central)
credentials, then bump `pom.xml` back to the next `-SNAPSHOT` version.

## License

MIT — see [LICENSE](LICENSE).
83 changes: 83 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Benchmarks

JMH benchmarks comparing `h2histogram` against
[HdrHistogram](https://github.com/HdrHistogram/HdrHistogram) on the
single-threaded record path.

## Method

The two libraries are compared at matched relative-error levels. Because both
use a log-linear bucket layout, the matched settings also produce counter
arrays of almost identical size, so the cache behaviour is directly
comparable:

| Precision | h2histogram | HdrHistogram | Counter array |
|-----------|-------------|--------------|---------------|
| `gp3` | `groupingPower=3` (12.5% error) | 1 significant digit (~10%) | ~4 KiB |
| `gp7` | `groupingPower=7` (0.78% error) | 2 significant digits (1%) | ~57 KiB |
| `gp14` | `groupingPower=14` (0.006% error) | 4 significant digits (0.01%) | ~6.3 MiB |

The `spread` parameter controls how many distinct buckets the pre-generated
value stream touches, moving the hot working set through the cache hierarchy:

- **`few`** — 64 buckets (512 B of counters; L1)
- **`quarter`** — a quarter of all buckets
- **`all`** — every bucket (up to ~6.3 MiB of counters at `gp14`; L3/DRAM)

Bucket indices are sampled uniformly; sampling uniform *values* would
concentrate nearly all samples in the top power of two of a logarithmic
layout. Both histograms are fed the identical value stream, and use
`maxValuePower=63` / `highestTrackableValue=Long.MAX_VALUE` so every value is
in range for both.

## Running

```bash
# from the repository root: install the library, then build and run
mvn -B install -DskipTests
cd benchmarks
mvn -B package
java -jar target/benchmarks.jar
```

Useful options: `-p precision=gp7 -p spread=all` to run a single cell,
`-prof perfnorm` for per-op cache-miss counters (Linux), `-rf json` for
machine-readable output.

## Results

Single-threaded, average time per recorded value (lower is better). Measured
on a 4-vCPU Intel Xeon @ 2.80 GHz cloud instance (32 KiB L1d / 1 MiB L2 per
core, 33 MiB shared L3), OpenJDK 21, JMH 1.37, 5×1 s iterations after 3×1 s
warmup, one fork. Treat the numbers as indicative — this is a shared cloud
machine.

| Precision | Spread | h2histogram (ns/op) | HdrHistogram (ns/op) |
|-----------|--------|--------------------:|---------------------:|
| gp3 (~4 KiB) | few | 3.77 ± 0.37 | 2.90 ± 0.12 |
| gp3 | quarter | 2.70 ± 0.08 | 2.88 ± 0.12 |
| gp3 | all | 1.99 ± 0.07 | 2.86 ± 0.23 |
| gp7 (~57 KiB) | few | 0.76 ± 0.02 | 2.94 ± 0.27 |
| gp7 | quarter | 2.74 ± 0.09 | 2.72 ± 0.04 |
| gp7 | all | 2.03 ± 0.16 | 2.82 ± 0.12 |
| gp14 (~6.3 MiB) | few | 0.76 ± 0.03 | 3.01 ± 0.19 |
| gp14 | quarter | 3.47 ± 0.15 | 5.05 ± 0.23 |
| gp14 | all | 3.86 ± 0.88 | 6.82 ± 0.65 |

Observations:

- **Linear-region fast path.** When every value lands in the linear region
(`few` at gp7/gp14, where the first 64 buckets are all width-1), h2's
index computation collapses to a bounds check plus an array increment:
~0.76 ns/op, roughly 4× faster than HDR on the same stream.
- **Large working sets favour h2.** At gp14 with the full ~6.3 MiB counter
array in play (past L2), h2 records ~1.8× faster than HDR at essentially
the same relative error, thanks to its cheaper index computation ahead of
the inevitable cache misses.
- **Mixed streams are a wash at moderate sizes.** At gp7/`quarter` the two
are within noise of each other; HDR is remarkably flat (~2.7–3.0 ns/op)
whenever its array fits in cache.
- The gp3/`few` cell is the one spread where h2 trails: at grouping power 3
the linear region is only 16 buckets wide, so a 64-bucket stream mixes
linear and logarithmic paths and pays for branch misprediction, while at
`all` the branch becomes predictable-enough again and h2 pulls ahead.
85 changes: 85 additions & 0 deletions benchmarks/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.iopsystems</groupId>
<artifactId>h2histogram-benchmarks</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<name>h2histogram-benchmarks</name>
<description>JMH benchmarks comparing h2histogram against HdrHistogram.</description>

<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jmh.version>1.37</jmh.version>
</properties>

<dependencies>
<dependency>
<groupId>com.iopsystems</groupId>
<artifactId>h2histogram</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.hdrhistogram</groupId>
<artifactId>HdrHistogram</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>benchmarks</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Loading
Loading