|
FZGPUModules 2.0
GPU-accelerated modular compression pipelines
|
Complete walkthrough for adding a new compression/decompression stage to FZGPUModules. Use scripts/new_stage.sh to generate the file skeleton automatically:
A stage is a single transformation in the pipeline (predictor, coder, transform, etc.). The pipeline interacts with every stage exclusively through the Stage base class interface — there is no casting or type-name branching anywhere in pipeline or DAG code.
Files you will touch for a new stage. scripts/new_stage.sh scaffolds and edits the ones marked *(script)*, so a hand-written stage really only fills in the kernels and the TOML support:
| File | What you do | Automation |
|---|---|---|
modules/<category>/<name>/<name>_stage.h | Stage class declaration | |
modules/<category>/<name>/<name>_stage.cu | CUDA kernels + execute(), plus one FZ_REGISTER_SIMPLE_STAGE / FZ_REGISTER_STAGE_FACTORY line that self-registers FZM-header reconstruction | *(script emits the line)* |
include/fzm_format.h | Add StageType enum value + stageTypeToString() case | *(script)* |
CMakeLists.txt (root) | Add .cu to fzgmod_modules library target | *(script)* |
tests/stages/test_<name>.cpp + tests/stages/CMakeLists.txt | Standard test set, registered | *(script)* |
src/pipeline/config.cpp | #include, addXxxStage + saveXxxStage helpers, one kStageRegistry[] entry — TOML load/save, the only shared file still edited by hand | |
include/fzgpumodules.h | Add the stage header include (public API export) | |
src/utils/cli/cli.cpp | *(Optional)* add the name to the --stages dynamic builder |
There is no central factory switch to edit — stage reconstruction self-registers from the stage's own .cu (Step 5). The one shared registration that remains is TOML load/save in config.cpp, because toml++ is deliberately confined to that translation unit.
Stages live under modules/ in one of these categories:
| Category | Path | Existing examples |
|---|---|---|
| Predictors | modules/predictors/<name>/ | lorenzo/, diff/ |
| Quantizers | modules/quantizers/<name>/ | quantizer/ |
| Coders | modules/coders/<name>/ | rle/, rze/, bitpack/ |
| Shufflers | modules/shufflers/<name>/ | bitshuffle/ |
| Transforms | modules/transforms/<name>/ | zigzag/, negabinary/ |
| Fused | modules/fused/<name>/ | lorenzo_quant/ |
Predictors decorrelate the data by computing residuals from a prediction model. The forward pass subtracts predicted values from actual values, producing a residual stream that has much smaller magnitude and is far more compressible. The inverse reconstructs the original by applying the cumulative prediction. The operation is always lossless. Use this category when your stage consumes raw data values and produces signed residuals (e.g. delta coding, Lorenzo predictor, interpolation).
Quantizers perform the lossy step: they map a continuous (floating-point) or fine-grained value to a discrete integer code within a user-specified error bound. Output codes are integers, typically uint16_t or uint32_t, and the stage emits a separate outlier stream for values that fall outside the representable range. The inverse reconstructs an approximation of the original values. Use this category when your stage introduces controlled, bounded loss to reduce dynamic range.
Coders compress an integer or byte stream losslessly by exploiting statistical redundancy — repeated values, long zero runs, or skewed symbol distributions. They do not reorder, predict, or transform the data; they only compact it. Output byte size is variable and must be smaller than input in the common case (or stored raw if not). The inverse exactly recovers the input stream. Use this category for entropy coders, run-length schemes, or any stage whose sole job is symbol-to-bitstream encoding.
Shufflers restructure the bytes of a stream without changing any values — they are size-preserving, lossless rearrangements designed to improve the compressibility of downstream coders. The canonical example is bit-matrix transposition (bitshuffle): grouping bit-plane k of all elements together so that sign bits and exponent bits form long runs of identical bytes. Use this category when your stage only reorders bytes/bits and the output is the same size as the input.
Transforms apply an invertible, element-wise mathematical mapping — every input element maps to exactly one output element of a (possibly different) type, and the mapping is exactly reversible. Examples: zigzag encoding maps signed integers to unsigned integers preserving magnitude order; negabinary maps signed integers to base-(-2) unsigned codes. Use this category when your stage is a bijective point-wise function with no inter-element dependencies, no size change, and no loss.
Fused stages combine two or more logically distinct operations (typically a predictor and a quantizer, or a predictor and a transform) into a single kernel to reduce memory round-trips. A fused stage is always a performance optimization: it is semantically equivalent to the un-fused stages wired in sequence, but avoids the intermediate buffer reads and writes. Use this category only when profiling shows the unfused version is memory-bandwidth limited and the stages are always used together.
If your stage does not fit cleanly into one category, prefer the one that best describes its primary effect on the data. A stage that both predicts and quantizes but is not performance-critical belongs in predictors/ or quantizers/ rather than fused/.
Create the directory: modules/<category>/<name>/
Copy the pattern from a nearby existing stage (e.g. modules/transforms/zigzag/zigzag_stage.h for a size-preserving transform, or modules/coders/rle/rle.h for a coding stage).
Required overrides:
For a single output, the default getOutputNames() returning {"output"} is fine. Multi-output stages override it:
Users connect to named ports: pipeline.connect(downstream, myStage, "codes").
If your stage changes the data size (encoding, packing, compression), handle both directions:
A forward-only implementation silently under-allocates the inverse output buffer. The pipeline bounds checker will catch this; without bounds checking it is silent memory corruption.
If your stage needs a reusable buffer across calls, override estimateScratchBytes() so the pool accounts for it, then allocate with persistent = true in execute():
Override isGraphCompatible() to return false if execute() contains any of:
cudaStreamSynchronize() on its own streamcudaMemcpy with DeviceToHost)If you need a D2H transfer only after the whole pipeline finishes, use postStreamSync() instead — the stream is idle there and graph capture is unaffected.
Stages that sync mid-execute are valid and supported. The pattern is used by HuffmanStage (histogram D2H for codebook build + partition metadata D2H) and any future ANS/arithmetic coder that needs CPU-side renormalization. Document the sync points and return false from isGraphCompatible().
Stages that require input sizes to be a multiple of a chunk size override:
Pipeline::finalize() computes the LCM of all stage alignments and pads the input transparently.
Enqueue all GPU work on stream — the pipeline manages inter-stage ordering through CUDA events. Do not call cudaDeviceSynchronize() or synchronize a different stream inside execute().
Calling cudaStreamSynchronize(stream) on your own stream is permitted when the algorithm inherently requires it (e.g. reading a GPU histogram to the host to build a CPU-side codebook, as in Huffman or ANS). When you do this:
isGraphCompatible() to return false — CUDA Graph capture records a snapshot of the command stream; a mid-execute sync prevents capture.docs/stages/<name>.md.execute() blocks the CPU from dispatching sibling nodes (stages at the same DAG level) until your stream is idle. In a linear chain this has no impact; in a wide DAG it delays parallel branches.If multiple input elements contribute to the same output location (packing, reduction), each thread must exclusively own its output slot or use atomics. Plain |= / += on a shared address without atomics is a data race even after a cudaMemsetAsync pre-zero.
Two patterns that avoid atomics for packing:
The library builds against CUDA or HIP (-DFZGMOD_BACKEND=HIP, targeting CDNA / MI100 gfx908). A new stage is expected to compile and run on both. This costs almost nothing if you follow one rule from the start, and is painful to retrofit:
Never name a CUDA entity directly. Route everything through include/backend/.
The facade is deliberately spelled with CUDA names — cudaMalloc, cudaStream_t, cub::BlockScan all keep working — and backend/api.h re-points them at HIP. So your kernel still reads as ordinary CUDA; what changes is the includes, plus the handful of warp/atomic intrinsics whose CUDA and HIP semantics genuinely differ.
| Instead of | Use | Why |
|---|---|---|
#include <cuda_runtime.h> in _stage.h | #include "backend/types.h" | HIP has no such header |
#include <cuda_runtime.h> in _stage.cu | #include "backend/api.h" | as above, plus the API remap macros |
#include <cub/cub.cuh> | #include "backend/cub.h" | aliases namespace cub = hipcub. Also prevents a bare cub include silently resolving to NVIDIA's cub during a HIP build on machines with a CUDA toolkit on CPATH |
thrust::cuda::par | fz::backend::detail::parOn(stream) | rocThrust uses thrust::hip::par |
__shfl_*_sync(0xffffffff, v, d) | fz::backend::shflUp/shflDown/shflXor/shfl(v, d, width) | the 32-bit mask is a hard static_assert failure under HIP, and the implicit width defaults to warpSize — 64 on CDNA. width is a required argument here so this can't happen silently |
__ballot_sync / __any_sync | fz::backend::ballotSync32 / anySync32 | on a 64-wide wavefront the upper lanes' ballot bits land at [32:63]; these restrict to the caller's own 32-lane half and normalize back to [0:31] |
atomicAdd_block / atomicOr_block / atomicMax_block | fz::backend::atomicAddBlock / atomicOrBlock / atomicMaxBlock | the _block suffix family is CUDA-only spelling; ROCm declares none of it |
| hand-rolled cub size-then-run scratch dance | fz::backend::withTempStorage() | already backend-neutral |
inline PTX / asm volatile / __lanemask_* | nothing — rewrite it | unportable; this is why the vendored dietgpu ANS tree is excluded from the HIP build entirely |
Ordinary device intrinsics need no change and behave identically: __ffs, __popc, __clz, __brev, __syncthreads, __ldg, unscoped atomicAdd/atomicMax, __launch_bounds__, <<<>>> launches.
A facade can correct the mask and the default width, but it cannot know whether your algorithm is intrinsically 32-lane. If a kernel does a 32-lane butterfly, packs a ballot into a 32-bit wire format, or does lane math like tid & 31 / tid >> 5, pass a literal 32 as width and comment why — do not let it inherit warpSize. Worked examples: modules/coders/adaptive_bitpack/adaptive_bitpack_kernels.cu (bit-transpose butterfly) and modules/coders/gpulz/gpulz_stage.cu (the anySync32(...) && (tid & 31) == 0 → shared flag pattern, which is correct on 64-wide wavefronts because each half computes its own answer and OR-writes the same flag).
cudaFuncSetAttribute(...,
MaxDynamicSharedMemorySize, ...) to exceed 48 KB on NVIDIA may not fit on AMD..cu to the unconditional source list in CMakeLists.txt (Step 6) — the only per-backend exclusion today is dietgpu ANS, and it is a documented stopgap.Before submitting, search the stage for raw CUDA runtime types and intrinsics, cross-check every device primitive against backend/api.h, and validate with the HIP configure/build preset described in Building from Source.
In include/fzm_format.h, add to the StageType enum:
Also add to stageTypeToString() in the same file:
Never renumber or reuse existing values. They are serialized in .fzm files; reusing a value corrupts files that contain the old stage type.
decompressFromFile() rebuilds each stage from its serialized header through createStage() (include/stage/stage_registry.h). There is no central switch to edit — a stage registers its own reconstruction from its .cu at file scope, so this step stays inside the stage's own directory.
For a stage with no template dispatch, one line at the bottom of <name>_stage.cu (after #include "stage/stage_registry.h") is enough:
If your stage is templated on a type, write a small factory that dispatches on the DataType byte(s) stored in the config header, then register it:
The registrar runs at static-init when libfzgmod_modules loads. scripts/new_stage.sh emits the FZ_REGISTER_SIMPLE_STAGE line for you.
Static-archive builds: registration relies on the stage's object file being linked in. In the default shared-library build (
BUILD_SHARED_LIBS=ON) every module object is present inlibfzgmod_modules.so, so registrars always run. If you link the modules as a static archive, link it with--whole-archive(or an equivalentKEEP) so the registrars are not stripped.test_stage_registryasserts every shippedStageTypehas a registered factory.
All stage .cu files belong to the fzgmod_modules target in the root CMakeLists.txt:
Add the stage header include to include/fzgpumodules.h (the main public API header) so users can access it with #include "fzgpumodules.h":
Organize includes alphabetically within each category for consistency.
To make your stage constructable from a .toml pipeline file via Pipeline::loadConfig() / saveConfig(), edit src/pipeline/config.cpp. The file uses a central kStageRegistry[] table — there are no scattered if/else chains or switch blocks to hunt down:
1. Add the header include at the top alongside the other stage includes:
2. Write a load helper (reads TOML keys, adds stage to the pipeline):
3. Write a save helper (writes TOML keys for saveConfig()):
4. Add one entry to kStageRegistry[]:
That's it. The type string (first field) is what appears in TOML files. Convention: class name without the Stage suffix (e.g. "Bitshuffle", "RZE", "Quantizer").
If your stage is templated, dispatch on input_type / code_type TOML keys in the helpers — see addLorenzoQuantStage / saveLorenzoQuantStage for the pattern.
If the stage makes sense as a general-purpose pipeline step, add it to the --stages builder in src/utils/cli/cli.cpp and update the help text.
This step is optional — stages that only make sense with specific type instantiations or unusual wiring can be TOML-only.
If your stage ports, adapts, or closely follows an algorithm from another project, you must acknowledge it in three places:
1. Source file comment — top of the .cu file:
For a direct port (kernel logic transliterated from upstream source), use:
2. Doxygen class comment — in the _stage.h header, inside the class-level /** ... */ block:
3. Stage documentation — at the end of docs/stages/<name>.md, add an ## Acknowledgements section:
4. THIRD_PARTY.md — if the upstream project is not already listed there, add a new entry with:
You do not need to do any of this for algorithms that are textbook-standard and not derived from a specific project's code (e.g., zigzag, negabinary, basic prefix sums).
Create tests/stages/test_<name>.cpp with at minimum:
| Test | What it checks |
|---|---|
ForwardRoundTrip | Forward + inverse produces exact or within-error output |
ZeroInput | n=0 does not crash or corrupt |
SerializeDeserialize | serializeHeader → deserializeHeader restores identical config |
PipelineIntegration | Stage wired into a Pipeline, compress + decompress round-trip |
SaveRestoreState | saveState + deserializeHeader + restoreState returns to original config |
GraphCompatible | isGraphCompatible() returns expected value |
Use tests/helpers/stage_harness.h. Pipeline integration tests must use a single Pipeline instance for both compress and decompress:
Do not create separate Pipeline objects for compress and decompress — decompress() builds the inverse DAG from the state of the same forward pipeline. The two-pipeline pattern only works via writeToFile/decompressFromFile.
Register in tests/stages/CMakeLists.txt:
<name>_stage.h — all required overrides implemented<name>_stage.cu — execute() enqueues on stream; if it calls cudaStreamSynchronize(stream), isGraphCompatible() returns false and sync points are documentedbackend/types.h + backend/api.h instead of <cuda_runtime.h>; backend/cub.h instead of <cub/...>; warp intrinsics and _block atomics via fz::backend::; any 32-lane algorithm passes a literal width = 32; no inline PTXStageType enum value added (unique integer, never reuse old values)stageTypeToString() case added.cu (FZ_REGISTER_SIMPLE_STAGE or FZ_REGISTER_STAGE_FACTORY).cu file added to fzgmod_modules in root CMakeLists.txtinclude/fzgpumodules.h (public API export)config.cpp — #include header, addXxxStage / saveXxxStage helpers, one entry in kStageRegistry[]cli.cpp — --stages name + help text *(if applicable)*saveState/restoreState implemented if deserializeHeader overwrites forward-pass configestimateScratchBytes() overridden if stage holds persistent pool allocationsgetRequiredInputAlignment() overridden if stage requires chunk-aligned inputisGraphCompatible() returns false if execute() does any D2H transfer.cu, @note in .h, ## Acknowledgements in docs/stages/<name>.md, entry in THIRD_PARTY.md (if upstream not already listed)