source
flyology_cachelines
0.1.1-devDevelopmentCache-line-aware storage and cache information for Ada
0.1.1-dev
DevelopmentSource documentation
README
Flyology Cachelines
Cache-line-aligned storage and cache information for Ada, with no runtime crate dependencies.
The Alire crate is flyology_cachelines; its Ada root package is
Flyology_Cachelines. It is a standalone related package in the Flyology
repository and does not depend on the Flyology runtime.
The crate is currently available on Linux and macOS. Windows support is not included in this adoption.
Why cache-line-aware storage?
Modern processors maintain cache coherence in cache-line-sized units. Two Ada tasks can update completely different objects and still contend if those objects occupy the same line: each write invalidates the other core's cached copy and the line repeatedly moves between cores. This is false sharing—the program has no logical shared value, but its physical layout creates interference.
Avoiding false sharing requires more than aligning the first object. Every independently written object must start on an appropriate boundary, and its size or array stride must keep the next object out of the same interference region. This crate packages those representation rules into reusable Ada types for stack objects, heap objects, records, and arrays.
The compile-time Destructive_Interference_Size is a conservative spacing
policy, not merely the detected physical cache-line size. For example, x86-64
normally reports a 64-byte line while this crate selects 128-byte spacing to
also separate adjacent-line prefetch pairs. Runtime cache queries are provided
for inspection and capacity planning; they do not change a type's compiled
representation.
This crate controls placement only. It does not make values atomic or make unsynchronized concurrent access safe. Use atomic types, protected objects, or clear task ownership independently of the chosen layout.
Choosing a representation
| Need | Package or type | Trade-off |
|---|---|---|
| Isolate every independently written value | Flyology_Cachelines.Padded |
Strongest separation; every value consumes one or more whole interference regions. |
| Keep a known number of same-owner values together | Flyology_Cachelines.Padded_Groups |
Explicit density; instantiation fails if the group would spill beyond one region. |
| Pack as many same-owner values as safely fit | Flyology_Cachelines.Fitted_Groups |
Maximum per-region density; capacity depends on the selected architecture spacing and element representation. |
| Treat multiple isolated groups as one logical sequence | Grouped_Array from either group package |
Flat indexing and iteration with physical group gaps; choose standard iteration or Fast_View for measured hot loops. |
Grouping is an ownership decision: elements within a group may share cache
lines and should normally be written by the same task or shard. Values with
independent writers should receive separate groups or individual Padded
wrappers.
Individually padded values
The generic Flyology_Cachelines.Padded package wraps any definite Ada type. Its
Padded type is aligned to a destructive-interference boundary and rounded up
to a whole number of boundaries, so adjacent objects do not falsely share the
same boundary.
with Flyology_Cachelines.Padded;
procedure Example is
package Padded_Counters is new Flyology_Cachelines.Padded (Natural);
Counters : array (1 .. 2) of Padded_Counters.Padded :=
[others => Padded_Counters.Create (0)];
begin
Counters (1).Value := Counters (1).Value + 1;
end Example;
Ownership-aware groups
Flyology_Cachelines.Padded_Groups packs an explicit number of elements into one
destructive-interference region. Elements inside a group may share cache lines;
separate groups cannot share the selected region. This is useful when every
group belongs to one task or shard:
with Flyology_Cachelines.Padded_Groups;
package Worker_Counters is new Flyology_Cachelines.Padded_Groups
(Element_Type => Atomic_Counter,
Group_Length => 8);
Counters : array (Worker_Id) of Worker_Counters.Group;
Group is itself an array, so one group is directly indexable and iterable:
for Counter of Counters (Worker) loop
Counter := Counter + 1;
end loop;
The instantiation is rejected at compile time if either its element array or the final aligned representation exceeds one region. It can therefore never silently turn a one-region group into a multi-region object.
Flyology_Cachelines.Fitted_Groups chooses the largest safe element count for the
architecture selected by the build:
with Flyology_Cachelines.Fitted_Groups;
package Worker_Counters is new Flyology_Cachelines.Fitted_Groups (Atomic_Counter);
-- Compile-time constant; for an eight-byte counter this is 16 when the
-- selected destructive-interference size is 128 bytes.
Capacity : constant Positive := Worker_Counters.Elements_Per_Group;
Both generics also provide Grouped_Array, which preserves the physical group
boundaries while presenting every logical element as one flat sequence:
Counters : aliased Worker_Counters.Grouped_Array :=
Worker_Counters.Create
(Element_Count => 2 * Worker_Counters.Elements_Per_Group,
Initial_Value => 0);
Counters (Worker_Counters.Elements_Per_Group + 1) :=
Counters (Worker_Counters.Elements_Per_Group + 1) + 1;
for Counter of Counters loop
Counter := Counter + 1;
end loop;
Flat indexing and mutable iteration cross group boundaries without copying.
Length returns the logical element count, and iteration skips unused elements
in a partial final group. Counters.Groups remains available when code needs
to assign or process whole physical groups directly.
There are two whole-sequence traversal choices:
- Direct
for Counter of Countersuses the standard Ada iterator protocol. It is the simplest choice and supports constant and mutable containers. GNAT currently implements the class-wide iterator with cursor dispatch, reference objects, and secondary-stack work, which can be expensive in a tiny hot loop. Fast_Viewuses GNAT's lightweightIterableaspect and a cached address cursor. It retains one-level mutablefor ... ofsyntax for measured hot loops.
Using Fast_View
Declare the Grouped_Array as aliased, then construct a short-lived view
with the container's 'Access. A reusable traversal procedure looks like this:
with Flyology_Cachelines.Fitted_Groups;
procedure Increment_Counters is
type Counter is mod 2 ** 64 with Atomic;
package Worker_Counters is new Flyology_Cachelines.Fitted_Groups (Counter);
procedure Increment_All
(Container : aliased in out Worker_Counters.Grouped_Array)
is
View : Worker_Counters.Fast_View (Container'Access);
begin
for Item of View loop
Item := Item + 1;
end loop;
end Increment_All;
Counters : aliased Worker_Counters.Grouped_Array :=
Worker_Counters.Create
(Element_Count => 10_003,
Initial_Value => 0);
begin
Increment_All (Counters);
end Increment_Counters;
The same Fast_View (Container'Access) pattern is available from
Flyology_Cachelines.Padded_Groups when the group length is chosen explicitly.
The usage rules are:
Fast_Viewoperates onGrouped_Array; a singleGroupis already an array and can be iterated directly.- The container must be
aliased. The view's access discriminant ties its lifetime to that container, and the limited view is intended to remain local to the traversal. - Creating a view allocates no storage and copies no elements.
- Iteration is mutable and flat. It visits exactly
Length (Container)logical elements, crosses aligned group gaps, and never exposes the unused tail of a partial final group. - The view adds no synchronization. Elements shared between tasks still need atomic operations, protected access, or an ownership scheme.
- A constant or read-only traversal should use the standard
for Item of Containerform instead.
Internally, the cursor stores the current element address, remaining logical element count, and slot within the physical group. Advancing uses the component stride inside a group and the aligned group stride at a boundary. This avoids logical-index division and the class-wide standard iterator machinery.
Fast_View is intentionally opt-in: Iterable is a GNAT-defined aspect and
the view is mutable-only. These group packages already target GNAT for their
compile-time spill checks; Fast_View adds another explicitly
implementation-defined dependency. Use it only after measuring the relevant
hot loop. The benchmark reports standard and fast-view traversal separately
against explicit group/slot traversal over identical storage.
If even one element cannot fit, instantiation fails. Both generics describe a compile-time destructive-interference region, not necessarily one physical cache line: x86-64 deliberately selects 128-byte regions even though its physical lines are normally 64 bytes. Grouping must be based on common ownership; automatically packing independently written values would recreate false sharing.
The root package also exposes:
Destructive_Interference_SizeHardware_Cache_Line_SizeL1_Data_Cache_SizeandL1_Data_Cache_SlotsL2_Cache_SizeandL2_Sharing_CoresCore_Class,Core_Class_Count,Core_Class_Ordering,Core_Class_Cores, andCore_Class_CPUsCache_Query_ResultandValue_Or
Runtime cache detection uses sysctlbyname on macOS and
/sys/devices/system/cpu on Linux. Linux x86-64 queries CPUID and other Linux
targets query sysconf when sysfs describes nothing at all. The host is
inspected once, on the first query rather than during package elaboration, and
the result is reused because cache geometry is stable for the life of a
process. Deferring the inspection matters because describing every core class
means reading one description per CPU on Linux, which costs tens of
milliseconds on a host with many CPUs; a program that never asks pays nothing.
Malformed or failed host queries return (Available => False) rather than an
ambiguous default value or a conversion exception.
declare
Line : constant Flyology_Cachelines.Cache_Query_Result :=
Flyology_Cachelines.Hardware_Cache_Line_Size;
begin
if Line.Available then
Put_Line ("hardware line:" & Line.Value'Image);
end if;
-- A fallback is only used when the caller explicitly requests one.
Put_Line (Flyology_Cachelines.Value_Or (Line, 64)'Image);
end;
The compile-time spacing follows the documented architecture table: 128 bytes for x86-64, AArch64, and PowerPC64; 32 bytes for ARM, MIPS, SPARC, and Hexagon; 16 bytes for m68k; 256 bytes for s390x; and 64 bytes otherwise.
Core classes
A host whose cores are not identical has no single L1 data-cache capacity. The crate groups such a host's cores into core classes, one per distinct cache geometry, and every cache query names the class it describes:
-- Without a class, queries describe the class the host ranks highest.
Fast : constant Flyology_Cachelines.Cache_Query_Result :=
Flyology_Cachelines.L1_Data_Cache_Size;
for Position in 1 .. Flyology_Cachelines.Value_Or
(Flyology_Cachelines.Core_Class_Count, 0)
loop
declare
Class : constant Flyology_Cachelines.Core_Class :=
Flyology_Cachelines.Core_Class (Position);
begin
Put_Line
(Flyology_Cachelines.L1_Data_Cache_Size (Class)'Image & " bytes across"
& Flyology_Cachelines.Core_Class_Cores (Class)'Image & " cores");
end;
end loop;
Core_Class_Cores counts physical cores and Core_Class_CPUs counts logical
ones. They differ under simultaneous multithreading, where sibling CPUs share
one core's L1 data cache: a 24-core host with two threads per core reports 24
cores and 48 CPUs in one class.
Hardware_Cache_Line_Size takes no class. macOS publishes no per-class line
size, and the core types of current heterogeneous parts share one line size.
L2 is shared
L2_Cache_Size reports a class's level 2 capacity, and L2_Sharing_Cores
reports how many cores share one such cache. The pair matters because L2 is
rarely private: a 12P/4E Apple silicon host reports 16 MiB of L2 shared by six
performance cores and 4 MiB shared by four efficiency cores, so neither figure
is a per-core budget. A server x86-64 part with two threads per core reports 2
MiB shared by one core, which is private. Divide by L2_Sharing_Cores before
sizing a per-core working set.
The flat hw.l2cachesize on Apple silicon reports the efficiency cluster, so it
understates the performance cluster by a factor of four — the same defect the
flat hw.l1dcachesize has at a factor of two. Naming a class avoids both.
How much the order means
Core_Class_Ordering reports what ordered the classes, because that differs by
platform and is sometimes an inference rather than a host-reported fact:
| Ordering | Meaning |
|---|---|
Host_Reported |
The host published a performance rank and the classes follow it. |
Inferred |
The host published no rank, so classes are ordered by descending L1 data-cache capacity. |
Unordered |
Fewer than two classes exist, or nothing distinguished them. |
macOS 12 and later publish per-core-type geometry as ordered performance
levels, where hw.perflevel0 is the highest-performing level, so macOS
reports Host_Reported. On Apple silicon the flat hw.l1dcachesize reports
the efficiency-core capacity: one 12P/4E host reports 65536 bytes for the flat
name against 131072 for hw.perflevel0.l1dcachesize. The crate uses the flat
name only on a host publishing no performance level, such as macOS 11 and
earlier or an Intel Mac.
Linux publishes per-CPU descriptions and no single ordering. Where the kernel
publishes cpuN/cpu_capacity — its own measure of relative CPU performance,
normalized to 1024 — that value both separates and orders the classes, and the
ordering is Host_Reported. Architectures selecting the generic topology code
publish it; x86-64 does not, so an x86-64 hybrid part falls back to ordering by
descending L1 data-cache capacity and reports Inferred. That inference holds
on current hybrid parts, where the higher-performing core has the larger L1
data cache, and carries no meaning on a host whose classes differ some other
way.
Why these alignment sizes?
The policy is adapted from the CachePadded implementation in
crossbeam-utils,
which is available under MIT or Apache-2.0. It is the same policy used by the
Rust crate this project ports.
128 bytes: x86-64, AArch64, Arm64EC, and PowerPC64
Starting with Intel Sandy Bridge, the spatial prefetcher can fetch pairs of
adjacent 64-byte cache lines. Aligning independent values to only 64 bytes can
therefore still create destructive interference through prefetching. The
128-byte spacing keeps independently written values out of the prefetched
pair. See Intel's
Optimization Reference Manual
and Folly's
hardware_destructive_interference_size.
AArch64 systems may combine asymmetric cores in a big.LITTLE design, and the larger cores can use 128-byte cache lines. See Mono's ARM64 cache-line discussion. PowerPC64 also conventionally uses 128-byte cache lines, as reflected in Go's PPC64 CPU definitions and the Linux PowerPC cache definitions.
32 bytes: ARM, MIPS, SPARC, and Hexagon
These targets conventionally use 32-byte cache lines. The policy follows the corresponding Go CPU definitions and the Linux architecture cache headers, including Linux SPARC and Linux Hexagon.
16 bytes: m68k
The m68k alignment follows the Linux m68k cache definition.
256 bytes: s390x
s390x uses 256-byte spacing, following Go's s390x CPU definition and the Linux s390 cache definition.
64 bytes: x86, WebAssembly, RISC-V, SPARC64, and fallback
These targets use the common 64-byte alignment. Unknown architectures also default to 64 bytes. Relevant references include Go's x86 CPU definition and the Linux RISC-V cache definition.
Build and test:
alr build
cd tests
alr test
The native test compares the architecture-selected interference spacing with
the queried physical cache-line size. The values need not be equal: x86-64
normally selects 128-byte spacing around a 64-byte hardware line. Instead, the
compiled spacing must be the architecture's expected value and an exact
multiple of the physical line. When the OS reports L1 capacity, the test also
checks that it consists of whole hardware lines and that
L1_Data_Cache_Slots derives the correct number of interference-sized slots.
Tests also distinguish an unavailable query with an explicit 64-byte fallback
from a successfully detected 64-byte line.
alr test also compares the five architecture-selected public specifications
after normalizing their alignment literal. This prevents the duplicated specs
from drifting as the public API or its documentation changes. On Linux, it
also invokes the private sysconf and sysfs detectors independently; the
ordinary native test exercises the public CPUID-first x86-64 path. On macOS, it
invokes the private sysctl reader directly and checks that the reported L1
capacity is the highest performance level the host publishes, and that the flat
name is used only when the host publishes no performance level. Compile-fail
tests verify that explicit and automatically fitted groups which would spill
beyond one region are rejected.
Benchmarks
The nested benchmarks crate compares compact and padded storage under nine
workloads. Same-task measurements cover sequential, coprime-stride, hot-set,
and standard and GNAT Iterable grouped traversal versus explicit group/slot
loops. Multi-task measurements cover
isolated hot counters, task-owned compact groups, fitted versus individually
aligned groups, interleaved ownership, and contiguous shards.
Defaults use one million elements, up to 16 tasks, and three alternating-order
samples.
cd benchmarks
alr -n build --release
./bin/benchmarks
See benchmarks/README.md for workload definitions, positional scale controls,
memory requirements, and interpretation guidance.
License
Licensed under either the MIT License or Apache License 2.0, at your option.
The architecture spacing policy is adapted from Crossbeam's CachePadded; see
NOTICE and the repository's LICENSE-MIT and LICENSE-APACHE files.