FZGPUModules 2.0
GPU-accelerated modular compression pipelines
Loading...
Searching...
No Matches
BitpackStage

Header: modules/coders/bitpack/bitpack_stage.h
Class: fz::BitpackStage<T>
Category: Coder (lossless)


What it does

Packs each element using only its low nbits bits into a dense byte stream.

  • Forward: T[] → uint8_t[] — ceil(n × nbits / 8) bytes.
  • Inverse: uint8_t[] → T[] — unpacks elements, zero-extending to full width.

Most useful after a Lorenzo predictor stage where small delta values only need a few bits of representation.


Template parameter

Parameter Constraint
T Unsigned integer type (see available instantiations below)

Available instantiations

Only these types are compiled and linked:

  • BitpackStage<uint8_t>
  • BitpackStage<uint16_t>
  • BitpackStage<uint32_t>

Using any other type will result in a linker error. Most common: BitpackStage<uint32_t> (to match typical quantizer code width).


Stage settings

Setting Purpose Notes
setNBits(nbits) Bits per element Power of two, 1..8 * sizeof(T); ignored when auto-detect is on
setAutoDetect(bool) GPU scan to pick nbits automatically Disables CUDA Graph compatibility while active
setBase(base) Frame-of-reference offset subtracted before packing Removes dead high bits; always lossless
setShift(shift) Right shift applied after the base subtraction Removes dead low bits; lossy unless those bits are zero
setAutoBase(bool) GPU min-reduce to pick base automatically Disables CUDA Graph compatibility while active
setAutoShift(bool) GPU OR-reduce to pick the largest lossless shift Disables CUDA Graph compatibility while active
setAdaptive(bool) Convenience: auto base + auto shift + auto nbits Fully adaptive lossless mode

Manual bit-width

pack->setNBits(nbits);

nbits must be a power of two in [1, 8 × sizeof(T)]. Default is 8 * sizeof(T) (identity, no compression).

T Allowed nbits
uint8_t 1, 2, 4, 8
uint16_t 1, 2, 4, 8, 16
uint32_t 1, 2, 4, 8, 16, 32

Violations throw std::invalid_argument at setNBits() time.

Auto-detect mode

pack->setAutoDetect(true);

When enabled, forward execute scans the input for its maximum value using cub::DeviceReduce::Max and selects the smallest valid power-of-two nbits that covers it. The chosen nbits is written into the compressed header so the inverse pass unpacks correctly without any out-of-band configuration.

After compress(), getNBits() reflects the detected value.

Scratch buffers for the scan are allocated through the pipeline's memory pool (with a transparent cudaMalloc fallback in vGPU / pool-fallback mode), so all device memory remains tracked by the pipeline.

CUDA Graph incompatibility: auto-detect requires a device-to-host transfer and stream synchronization to read the max value, making it incompatible with CUDA Graph capture. isGraphCompatible() returns false while auto-detect is on. If you know the bit-width ahead of time, use setNBits() instead to keep graph capture available.

Output size estimate: estimateOutputSizes() returns the worst-case (full input size) when auto-detect is enabled, so PREALLOCATE mode reserves sufficient space regardless of the detected nbits.


Shift: frame-of-reference base + low-bit right shift

nbits alone only helps when the useful information already sits in the low bits of each word. The shift transform moves it there first:

forward: packed = (v - base) >> shift (low nbits bits kept)
inverse: v = (packed << shift) + base

The two knobs attack opposite ends of the word and compose:

Knob Removes Lossless?
base dead high bits — values clustered far from zero always
shift dead low bits — values that are all multiples of 1 << shift only if those bits are zero

Both default to 0, which is the identity: behaviour is unchanged unless you opt in.

‍**setShift() is lossy in general.** The inverse restores (packed << shift) + base, so any low bits you drop are gone — values come back rounded down to a multiple of 1 << shift. Use setAutoShift(true) if you want the largest shift that is provably lossless for your data.

Adaptive mode

bpack->setAdaptive(true); // = setAutoBase + setAutoShift + setAutoDetect

Forward execute then runs three GPU scans, in this order, and writes all three results into the compressed header:

  1. basecub::DeviceReduce::Min over the input.
  2. shift ← trailing-zero count of an OR-reduce of every (v - base). This is the largest shift that drops no information, so adaptive mode is always lossless.
  3. nbits ← smallest power of two covering (max - base) >> shift.

After compress(), getBase(), getShift(), and getNBits() reflect the detected values.

Worked example — values of the form 1000 + k*16 for k in [0, 15]:

value bits needed
raw uint16_t 1000 … 1240 16
after base = 1000 0 … 240 8
after shift = 4 0 … 15 4

A 4× gain that neither knob delivers on its own. The individual setters are still available if you want only one half (e.g. setAutoBase(true) with a hand-set nbits, keeping the shift at 0).

CUDA Graph incompatibility: each auto mode needs a device-to-host readback, so isGraphCompatible() returns false while any of setAutoDetect, setAutoBase, or setAutoShift is on. Hand-set base/shift/nbits stay graph-capturable.

File format: the serialized header is 15 bytes — the previous 10 plus shift (1 byte) and base (4 bytes, zero-extended). Archives written before this option decode with shift = base = 0.


Typical pipeline

Manual <tt>nbits</tt>

p.setDims(nx);
auto* quant = p.addStage<QuantizerStage<float, int32_t>>();
auto* lrz = p.addStage<LorenzoStage<int32_t>>();
auto* bpack = p.addStage<BitpackStage<uint32_t>>();
bpack->setNBits(16); // pack small Lorenzo deltas into 16 bits
p.connect(lrz, quant, "codes");
p.connect(bpack, lrz);
p.finalize();

Auto-detect <tt>nbits</tt>

p.setDims(nx);
auto* quant = p.addStage<QuantizerStage<float, uint16_t>>();
auto* lrz = p.addStage<LorenzoStage<int16_t>>();
auto* bpack = p.addStage<BitpackStage<uint16_t>>();
bpack->setAutoDetect(true); // let the stage pick the tightest nbits at runtime
p.connect(lrz, quant, "codes");
p.connect(bpack, lrz);
p.finalize();
p.compress(d_in, n_bytes, stream);
// bpack->getNBits() now holds the detected value (e.g. 4 for small deltas)

Adaptive <tt>base</tt> + <tt>shift</tt> + <tt>nbits</tt>

p.setDims(nx);
auto* quant = p.addStage<QuantizerStage<float, uint16_t>>();
auto* lrz = p.addStage<LorenzoStage<int16_t>>();
auto* bpack = p.addStage<BitpackStage<uint16_t>>();
bpack->setAdaptive(true); // auto base + auto shift + auto nbits, all lossless
p.connect(lrz, quant, "codes");
p.connect(bpack, lrz);
p.finalize();
p.compress(d_in, n_bytes, stream);
// bpack->getBase() / getShift() / getNBits() now hold the detected values