DEV Community

Stack Builders
Stack Builders

Posted on Originally published at stackbuilders.com

Massiv vs. Accelerate: Two Paths to High-Performance CPU Computing in Haskell

Programmers commonly use languages like Python, or C/C++ for those seeking higher performance, to handle array manipulation with libraries such as NumPy, SciPy, or deep learning frameworks like TensorFlow. Although Haskell is not a mainstream language, some developers favor its functional paradigm and strong typing.

Importantly, Haskell offers its own libraries for high-performance array manipulation. This article will introduce two of the most popular Haskell libraries for this purpose: Massiv and Accelerate.

Architectural Philosophies: Massiv vs. Accelerate

Massiv

Massiv, whose name comes from the Russian word for array, is a high-performance library for array manipulation. Unlike other libraries that treat arrays as objects in memory, Massiv uses the concept of "Delayed Arrays." These delayed arrays are represented not as objects but as functions or a composition of functions.

For instance, a delayed array can be defined as a function that maps an index (ix) to an element (e), with the type signature ix -> e. The library provides numerous array types, along with the necessary type families for describing array dimensions and the required functions.

To achieve performance in array manipulation, Massiv utilizes stencil computations. This technique involves manipulating an array based on a fixed pattern, applying a computation within the neighborhood of the stencil's center, which is the zero index in Massiv. Massiv's documentation suggests that implementing this stencil computation in a functional way is more efficient.

Accelerate

Accelerate positions itself as a domain-specific language, or eDSL, for array-based computations designed for massive parallelism embedded within Haskell. In contrast, Massiv is a library. Accelerate programs are defined using parameterised collective operations. Accelerate then takes these computations, compiling and optimizing them specifically for the target architecture.

Accelerate uses the Array as its primary computational unit. Computations are executed as collective operations across arrays of the type Array sh e, where sh represents the array's shape or dimension and e is the type of the elements.

All Accelerate programs receive zero or more arrays as input and produce one or more arrays as output. The array elements are stored in a memory layout, specifically an unboxed, unzipped struct-of-array representation, that allows them to be efficiently transferred to and utilized by various devices, such as a GPU.

For more information, you can read the definition for Accelerate Arrays, which has a good explanation of how they are defined and used.

Code implementation of multidimensional array processing

To compare the performance of both libraries, I implemented a box blur, a process that applies a filter to blur an image. The specific logic involved applying a 3x3 average filter to a 3162x3162 image.

Here are the implementation details:

runStencilBenchmarks :: IO ()
runStencilBenchmarks = do
    let side = 3162
    let sz = M.Sz2 side side

    -- Massiv Setup
    let mArr = M.makeArray M.Par sz (\(i M.:. j) -> fromIntegral (i + j)) :: M.Array M.P M.Ix2 Double

    -- Accelerate Setup
    let aArr = A.fromList (A.Z A.:. side A.:. side) [0..(fromIntegral (side*side - 1))] :: A.Array A.DIM2 Double

    defaultMain [
        bgroup "3x3 Mean Blur" [
            bench "Massiv (Stencil)" $
                nf (M.computeAs M.P . M.mapStencil (M.Fill 0.0) massivMeanStencil) mArr,

            bench "Accelerate (LLVM Stencil)" $
                whnf (Native.run . A.stencil accelMeanStencil A.clamp . A.use) aArr
        ]
      ]
Enter fullscreen mode Exit fullscreen mode

The code implementation and setup instructions are available in this GitHub repository. It includes setup instructions for both bare-metal and Docker environments.

The code was run with the following hardware specifications:

  • Apple M5 Pro
  • 24 GB RAM
  • macOS Tahoe

Benchmarking CPU Throughput and Resource Utilization

We used Criterion for performance analysis. Running the comparison produced the following results:

Massiv and Accelerate benchmark results

Whoa. Those results are staggering.

The performance disparity is stark: we observed a 5.4x difference. Massiv required approximately 9.08 ms, whereas Accelerate finished in about 1.67 ms.

This clear gap underscores the value of these analyses. Though theoretically equivalent, Accelerate's specialized runtime execution engine significantly outperforms Massiv's native compiled loops in practice when dealing with parallel grid operations.

The results can be summarized in the following table:

Metric Massiv (GHC Native + LLVM) Accelerate Delta or speedup
Mean execution time 9.08 ms 1.67 ms Accelerate is approximately 5.4x faster
Best-run latency 8.93 ms 1.53 ms Accelerate reaches a considerably lower latency
Execution consistency (R²) 0.997, ultra-stable 0.993, higher outlier variance Massiv is more predictable

After comparing their execution times, we can examine how the libraries differ in resource and CPU utilization.

Massiv run

To analyze resource utilization for Massiv, execute:

cabal run massiv-vs-accelerate -- stencil-massiv +RTS -s
Enter fullscreen mode Exit fullscreen mode

Massiv resource utilization results

Accelerate run

To analyze resource utilization for Accelerate, execute:

cabal run massiv-vs-accelerate -- stencil-accel +RTS -s
Enter fullscreen mode Exit fullscreen mode

Accelerate resource utilization results

Memory and resource profiling comparison

The following results were collected on a bare-metal M5 Pro:

Metric Massiv Accelerate Structural difference
Total allocated in heap 43.95 GB 259.25 GB Accelerate allocates 5.9x more metadata
Peak memory in use 309 MiB 667 MiB Massiv is 2.1x more lightweight
Garbage collections 617 collections 3,950 collections Accelerate triggers 6.4x more GC runs
Total GC time (elapsed) 0.084 seconds 0.510 seconds Accelerate spends 6x longer in GC pauses
Elapsed productivity 98.1% 90.6% Massiv keeps the CPUs focused on mutation

The results reveal a classic engineering trade-off: Accelerate provides specialized hardware acceleration at the cost of higher memory overhead and runtime setup metadata, while Massiv provides seamless language integration and strict resource efficiency at the cost of raw throughput.

Looking under the hood through the GHC Runtime System using +RTS -s, Accelerate requires significantly more resource backing to orchestrate its execution. Over the course of the benchmark suite, Accelerate generated 259.25 GB of temporary heap allocations and peaked at 667 MiB of total memory in use, forcing GHC to trigger nearly 4,000 garbage collection cycles.

Massiv, by contrast, showcases impressive language-level frugality. It peaked at just 309 MiB of active memory and generated only 43.95 GB of total allocations. This allowed its parallel worker loops to spend 98.1% of elapsed time executing pure data mutation with virtually no garbage collection interference.

Throughput and latency

Accelerate is the undisputed winner in raw speed. With a median time of approximately 1.46 ms compared to Massiv's approximately 8.91 ms, Accelerate achieved a 6.1x performance lead.

  • Why Accelerate performs this way: Accelerate leverages LLVM to generate SIMD, or Single Instruction, Multiple Data, instructions. It utilizes the AArch64 Neon units of bare metal, a Mac in our case, to process multiple pixels in a single clock cycle.

  • Why Massiv performs this way: Massiv relies on GHC's optimization. While GHC is excellent at loop fusion, it is historically conservative with vectorization, resulting in scalar execution that processes pixels one by one.

Memory and resource management

Massiv is the winner in system politeness and footprint.

  • Heap stability: Massiv achieved a near-perfect 98.1% elapsed productivity score. This means the GHC Garbage Collector stayed out of the way, allowing the CPU to focus almost entirely on math.

  • System tax: Massiv's total memory footprint was only 309 MiB, compared to Accelerate's 667 MiB. Accelerate's higher RAM usage is the cost of entry for the LLVM JIT engine and its native memory buffers.

  • Allocation intensity: Accelerate showed an allocation rate of approximately 6.1 GB/s. While this is not leaked memory, it indicates a high volume of administrative traffic between the Haskell Runtime and native LLVM code.

Final thoughts

When to choose Massiv

  • Predictability and ease of deployment: Choose Massiv if your priority is predictability and ease of deployment.

  • Pure Haskell ecosystem: It behaves like pure Haskell at runtime, meaning your production environment does not need to manage a dynamic runtime JIT compilation layer.

  • Complex logic interleaving: It excels in complex algorithms where you need to interleave standard Haskell logic with array processing.

  • Remarkable memory efficiency: It is remarkably memory-efficient, peaking at just 309 MiB and keeping your application suitable for environments with strict RAM limits.

When to choose Accelerate

  • Computational throughput: Choose Accelerate if your priority is computational throughput.

  • Heavy, latency-critical tasks: If you are processing high-resolution video frames, large-scale signals, or heavy matrices where every millisecond counts, the 6.1x speedup can justify the additional 358 MiB of RAM.

  • Compiling to the metal: It is the closest Haskell gets to writing low-level performance code, providing a high-level embedded DSL that compiles directly into vector instructions on the metal.

Bonus: Docker vs. bare metal

Docker benchmark results

Bare-metal benchmark results

Environment comparison matrix

Metric Docker container (virtualized Linux) Bare-metal Apple M5 Pro (macOS Tahoe) Bare-metal difference
Massiv stencil 3.39 ms 8.93 ms 2.63x slower on bare metal
Accelerate stencil 1.78 ms 1.53 ms 1.16x faster on bare metal
Performance gap Accelerate was 1.9x faster Accelerate was 5.8x faster Native environment favors JIT pipelines

Comparing these environments reveals a fascinating divergence. While Accelerate achieves a modest speedup on bare metal by freeing its JIT compiler from hypervisor overhead, Massiv actually performs slower outside the container.

This suggests that GHC's native multithreaded worker loops for Massiv thrived under the Linux kernel's thread orchestration inside Docker. Running natively on macOS Tahoe, meanwhile, heavily widens the performance gap in favor of Accelerate's hardware-mapped vector units.

Related resources

To help you gain a deeper understanding of both libraries, here is a list of related resources.

1. Accelerate (Data.Array.Accelerate)

Focus: Deeply embedded language, GPU and CUDA offloading, and LLVM runtime compilation.

Academic papers

2. Massiv (Data.Massiv.Array)

Focus: Native Haskell multicore CPU evaluation, explicit indexing, stencil computation, and work-stealing schedulers.

Massiv ecosystem extras

  • massiv-io: High-performance I/O for loading and saving image formats such as PNG, JPG, and TIFF directly into Massiv multidimensional arrays.

  • lehins/massiv-compat: Interoperability adapters for converting Massiv arrays to and from vector and Repa structures.

Top comments (0)