histogram

package module
v0.0.0-...-ed5354b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 7, 2026 License: BSD-3-Clause Imports: 0 Imported by: 0

README

go-simd/histogram

histogram

ci coverage Go Reference

The fastest correct byte-value histogram in pure Go (CGO_ENABLED=0, stable Go, no assembly): count how many times each of the 256 possible byte values occurs in a []byte.

import "github.com/go-simd/histogram"

c := histogram.Count(data) // c[v] == number of bytes in data equal to v

var acc [256]uint32
histogram.CountInto(&acc, a) // accumulate several slices into one table
histogram.CountInto(&acc, b)

Count returns a fresh [256]uint32 (no heap allocation). CountInto adds into a caller-supplied array, so you can fold multiple buffers into one histogram.

Why there is no SIMD kernel (the honest answer)

A byte histogram is a scatter-add: c[data[i]]++. Each input byte indexes a different, data-dependent counter and read-modify-writes it. That is precisely the access pattern SIMD does not have:

  • AVX2 and ARM NEON have gather but no scatter. You cannot vectorise c[idx]++ across a lane vector because two lanes may target the same counter (a collision) and the writes would race within the instruction.
  • Only AVX-512 has a true vector scatter — and the dev/CI hardware here is Zen3-class with no AVX-512. Even where scatter exists, colliding bins serialise, so it rarely beats a good scalar loop on real (skewed) data.

So the win does not come from SIMD. It comes from instruction-level parallelism: keep several independent [256]uint32 partial tables and round-robin consecutive bytes across them, then sum the tables once at the end. Spreading neighbouring bytes across distinct memory cells breaks the store-to-load dependency that stalls a single-table loop whenever a byte value repeats — the classic "counting bytes fast" trick from FSE (fastcompression.blogspot.com).

Count uses eight partial tables with word-batched loads. This was the fastest of every variant measured (single, 2/4/8 tables, with and without word-batching): eight tables fully hide the increment latency even on adversarial low-entropy input, where a single-table loop collapses.

Performance

Throughput on a 1 MiB buffer, go test -bench . -count=6, best-of run. Two input distributions: uniform random bytes, and skew90 (90% one value — the low-entropy case that wrecks a naive single-table loop).

Native amd64 (authoritative — GitHub Actions ubuntu-latest)
approach uniform GB/s skew90 GB/s
single table (naive baseline) see CI see CI
4 partial tables see CI see CI
8 word-batched tables (Count) see CI see CI

The amd64 row is filled from the bench workflow. The dev machine is arm64, so amd64 numbers must come from native CI, not from Rosetta (which has no AVX2 — irrelevant here since we ship no asm, but the timing is still unrepresentative under emulation).

arm64 (indicative — Apple M-series dev box)
approach uniform GB/s skew90 GB/s
single table (naive baseline) 3.73 0.58
4 partial tables 3.77 2.28
8 word-batched tables (Count) 4.52 2.83
s390x (real z15 — LPAR guest, VXE2, Ubuntu 6.8, go1.26.4, 2026-07-03)

Multi-lane conflict avoidance still helps on Z even without a native scatter — but the two-thread shared LPAR ceiling is well below arm64:

approach uniform GB/s skew90 GB/s
single table (naive baseline) 1.06 0.49
4 partial tables 1.81 0.70
4 word-batched 2.15 0.68
Count (8 word-batched) 1.70 0.95

Multi4Word (word-batched 4-table) is the fastest kernel on this shared LPAR at 2.15 GB/s uniform = 2.03× single-table. Absolute z15 throughput will be higher on a dedicated partition; the ratios are what matter here.

The finding: SIMD does not help a byte histogram on AVX2/NEON hardware — it is a scatter, and there is no scatter instruction. The multi-table scalar loop is the real winner. On uniform data it edges out the single-table baseline; on skewed data it is ~5× faster, because the single-table loop serialises on the repeated counter while the eight tables keep the pipeline full.

Correctness

Count is validated against a trivial single-table reference oracle by a table test (empty, single byte, all-same, every-byte-once, block-boundary sizes), random-size property tests, a skewed-data test, and a FuzzCount differential fuzz target (run for 15 s in CI on both amd64 and arm64).

The test suite covers 100 % of the Go statements (CI fails below that on both the amd64 and arm64 jobs). This package is pure Go — there are no generated .s kernels — so the coverage figure reflects the whole implementation.

Existing work

There was no pure-Go library exposing a fast Count(data) [256]uint32 API. vteromero/byte-hist is a CLI tool, not a reusable package, and valyala/histogram is a float-quantile sketch, unrelated to byte frequency counting. This repo fills that gap.

License

BSD 3-Clause. See LICENSE.

Documentation

Overview

Package histogram counts how many times each of the 256 possible byte values occurs in a []byte — a byte-value histogram (a.k.a. frequency table), the building block of entropy coders (FSE/Huffman/rANS), data profiling and compression heuristics.

A byte histogram is a scatter-add: c[data[i]]++. That access pattern does not map onto AVX2/NEON, which have gather but no scatter (only AVX-512 has a true vector scatter, and even there contended bins serialise). The fast, portable technique is therefore not SIMD but instruction-level parallelism: keep several independent [256]uint32 tables and round-robin consecutive bytes across them, then sum the tables once at the end. Spreading neighbouring bytes over distinct memory cells breaks the store-to-load dependency that otherwise stalls a single-table loop whenever a value repeats — exactly the case (skewed/low-entropy data) where a naive histogram collapses.

Count uses eight partial tables with word-batched loads. See the package README for the measured throughput and the reasoning behind choosing this over both a single-table scalar loop and a SIMD attempt.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Count

func Count(data []byte) [256]uint32

Count returns the byte-value histogram of data: the returned array's element i is the number of bytes in data equal to i. It allocates nothing on the heap (the result is returned by value) and is safe for concurrent use on distinct slices.

func CountInto

func CountInto(c *[256]uint32, data []byte)

CountInto adds the byte-value histogram of data into *c, leaving the existing counts in *c untouched (it accumulates). Pass a zeroed array for a fresh histogram, or call it repeatedly to fold several slices into one table. c must be non-nil.

It keeps eight independent partial tables so that runs of equal bytes land in eight different counters, breaking the store-to-load dependency chain that stalls a single-table loop on repetitive input; the partials are summed back into *c at the end.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL