Sparse Arrays and type composition in Zig
(1504 words)One of the lovely things about Zig is the way that you can just create types at compile time and then use these types elsewhere in your code. The other day, I needed a sparse array, but the standard library didn’t really have what I wanted. I could have used one of the hash-map types, but that wasn’t really what I wanted, so I created my own.
pub fn SparseArray(IndexType: type, DataType: type) type {
}
The first thing I did was create a function that returns a type and takes in the types that I’d be using for the index and data types.
I then defined a few constants that would help with the computation of the actual data structure.
const page_size = 64 * 1024;
const data_size = @sizeOf(DataType);
const data_num = @divFloor(page_size, data_size);
const max_index = std.math.maxInt(IndexType);
const page_table_size = max_index / data_num + 1;
const DataTable = [data_num]DataType;
I defined a page size, that defines the size of each page of data within this sparse array. Essentially, what I want is an array that can hold lots of data, indexed by an normal index value, but that if there were large gaps in the data then no memory was wasted. The smaller the page size, the more efficient this is with regards to the memory waste, but the more overhead we had. For example, if we had a page size that was just the size of a single bit of data then we just have an array that allocates memory for each index when it was used.
I then calculate the number of data items that can be stored in a single
page. Obviously, this is the page size divided by the size of a bit of
data. I then determine the maximum index value based off the IndexType
that was passed in. For example, if this was just a 16-bit integer, then
the maximum index would be 65535. From this, we can determine the page
table size.
We also define a new type, only used within the context of this function
called DataTable
, that holds the data for a single page.
return struct {
page_table: [page_table_size]?*DataTable = .{null} ** page_table_size,
const This = @This();
After this, our function returns a structure. Remember, we are in a function that is returning a type and therefore we return a struct type back to the caller.
We also define that this structure has a single page_table
, of the
appropriate size that optionally points to a DataTable
, that is all
initialized to null
.
Finally, we define that within this struct, the constant This
is
defined to be this structure using the @This()
builtin.
We have a page table, all initialized to null, we need a few member functions that can operate on this new type.
pub fn deinit(self: This, alloc: std.mem.Allocator) void {
for (self.page_table) |pt| {
if (pt) |p| {
alloc.free(p);
}
}
}
The deinit
function takes a This
, and an allocator, and frees up any
memory that was allocated. Obviously, if you just create an instance of
this type and then immediately .deinit
ing it, this would just be a
no-operation.
The for
syntax just iterates over all the elements in the page_table
this this instance, and for each it captures the value into pt
. It
then checks that this optional value is a value or null. If the value is
not-null, then this is captured into p
that can then be used to free
that page_table entry.
pub fn set(
self: *This,
alloc: std.mem.Allocator,
index: IndexType,
data: DataType,
) !void {
const page_num = @divFloor(index, data_num);
if (self.page_table[page_num]) |page| {
const offset = @mod(index, data_num);
page[offset] = data;
} else {
const page = try alloc.create(DataTable);
@memset(page, 0);
const offset = @mod(index, data_num);
page[offset] = data;
self.page_table[page_num] = page;
}
}
To set a value into the sparse array, we use the set
function. This is
a member function that takes an allocator, an index value, and a data
value. Note that the index is of type IndexType
, and data is of type
DataType
, that were defined in the SparseArray
function we are
defining the return type for.
The function is fairly simple. We first compute the page number that our data should be in. If that page table entry is not-null, then we just index into that page entry and set the data.
Otherwise, we allocate a new page table entry using the allocator.create
and the type DataTable
. We then use the builtin @memset
function to
clear this memory to all zeros. This is not necessarily required, and
may be a performance problem if we are constantly creating new page
table entries, but frankly, I’d prefer zeroed memory to weird bugs in
the future. Note also, that the try alloc.create
will pass any
allocator error, like an out of memory error, up to the caller to work
out what the do. Then we just use the same offset calculation, set the
data, and make sure that the page_table entry is correct.
pub fn get(self: This, index: IndexType) ?DataType {
const page_num = @divFloor(index, data_num);
if (self.page_table[page_num]) |page| {
const offset = @mod(index, data_num);
return page[offset];
}
return null;
}
The get
function is even easier. Check if the page table entry for the
page number we require is not-null, then just capture that page,
determine the offset, and return the data. Otherwise, we just return
null.
Yes, this does not know if that data was ever set in the past, but it is an array not a hash map. Arrays are simple, hash maps are more complex. Arrays are faster, although sometimes you’ll get data back that was never set, which is why I zero’ed the memory in the first place.
Can we optimize this a little? Perhaps. We could define a put
function
that sets a value but knows that the page table should be set and
therefore would never allocator memory or fail due to an out of memory
error. Note that this doesn’t error in the unhappy case. This is a
design decision that may not be valid for everybody, but it is fine for
my usecase.
pub fn put(self: *This, index: IndexType, data: DataType) void {
const page_num = @divFloor(index, data_num);
if (self.page_table[page_num]) |page| {
const offset = @mod(index, data_num);
page[offset] = data;
}
}
And we should never forget testing.
test "sparse creation" {
var data: SparseArray(u16, u32) = .{};
defer data.deinit(std.testing.allocator);
try std.testing.expectEqual(null, data.get(0));
}
My first standard test is the ‘can you create and destroy it without leaking memory’ test. That’s what the sparse creation test is doing.
test "sparse set indexes" {
var data: SparseArray(u24, u8) = .{};
defer data.deinit(std.testing.allocator);
try std.testing.expectEqual(null, data.get(10));
try data.set(std.testing.allocator, 10, 37);
try data.set(std.testing.allocator, 11, 39);
try data.set(std.testing.allocator, 300000, 42);
try std.testing.expectEqual(42, data.get(300000));
try std.testing.expectEqual(37, data.get(10));
try std.testing.expectEqual(39, data.get(11));
data.put(10, 79);
try std.testing.expectEqual(79, data.get(10));
}
The next test is attempting to test the functions we’ve defined above.
Create a SparseArray
. Note, the types used are different from the
first test. Is this deliberate? No. But it could be a better test
because we did that, so it stays. We first check that we can’t read an
empty page for index 10. Then we write some data into the array at
indexes 10, 11, and 300000. This will create a couple of pages of data.
We then check that we can get that data back out. And then we test to
see if we can put an index we’ve already used without passing in an
allocator, and check that it worked. A very simple test.
The testing is much more through later on, but only because I use this data structure as part of a larger system and the testing of that system does much more than just exercise a couple of sets and puts and gets.
How much memory did the above test allocate? Well, each page has 64k of
memory, and given that we use it to hold u8
data types, that means
that we have 65536 entries per table. This means that the data_num is
65536 and that indexes 10 and 11 are in page 0, and index 300000 is in
page 4. This means we have to have 2 page table entries used, about 128
kbytes.
Could we make this more efficient? Perhaps. We could pass the page size into the function. We could just define a smaller page size to start with. However, this is sufficient for my needs, so I’m sticking with what I have.
Could I have done this in C++ with Templates? Yes, but I’m not sure that
I’d really want to see all those <
and >
brackets everywhere.
Passing in types into a function and returning a type feels elegant, the
whole concept of having a different mechanism to do this now feels
alien. Yes, I understand that languages like C++ are stuck in the legacy
of the past
design decisions,
but that doesn’t mean that new languages like Zig have to follow along
the same way.