This project is a reaction to the chaotic state of affairs of the NNUE format, used by modern chess engines. For a new user of already trained .nnue files, it is next to impossible to get it right. The people who create those .nnue files do not respect the "data contract" philosophy. To them it appears to be more a "stream of consciousness". Don't be one of those guys who just blurt out unstructured binary files and go public with it. If you use nncontainer, at least, you allow users to explore, inspect and use your files without having to study the template-maze of the stockfish implementation.
As is the case in training and then using networks for chess (and shogi, I guess) engines, there is one set of applications, which train networks. They basically do a write only operation. This can be done, using the write mode of nncontainer.
The second stakeholders are the chess engines, which want to use those files, thus foregoing energy wasting re-training. They just want to re-use an already trained network. They use the read-mode of nncontainer.
This container is not designed for mixed (read and write) mode.
- Network creator writes a nncontainer file (.nnc).
- Chess engine reads a nncontainer file (.nnc).
Such is the scope of this project.
The project is written in C17 C, using unix (linux) APIs. It is not (yet?) made portable for other operating systems, such as Windows. That aside, it should be possible to create FFI bindings for higher level and even for criminally slow langauges... The project is coming with a Makefile, which binds the nncontainer to a main.c program, which can serve as example code. My own testing was supplemented by asking Gemini AI to review the code and look for bugs.
Don't expect them to be perfect (for now). But they should provide you with a good starting point.
The flat container is a sequence of entries. After Initializing a new container, the function pair NNBegin(), NNEnd() allows appending a new entry
to the container.
The writer can specify for each entry, which primitive data type it is (for both SCALAR and VECTOR entries). It can also specify the alignment.
Thus, if a chess engine wants to use AVX2 operations, for example, a 64byte aligned vector of weights can be defined.
Each entry in the container has an ID, which can be used to document and define the semantics of an entry by the creator.
#define NNUE_LAYER_0_WEIGHTS 42
#define NNUE_LAYER_0_BIASES 43Thus, the users do not need to guess and research to find out which is which. The format of such an entry can be inspected and is contained in the file. For other things like specific scaling values, simply add another (SCALAR) entry with a documented ID. The different network topologies can also be enumerated and documented, in an entry of its own, thus sparing users to guess, which kind of NNUE network semantics this file assumes.
The user of a .nnc file has two options to get the data into their program.
- Load file into the heap
- Memory map the file (mmap() etc.). For both cases, there is a function in the API of nncontainer, respectively. In both cases, the alignments of the entries are correct and SIMD friendly (if the creator of the file so willed it).
The code snippets below show how I currently (still in the early stages) intend to use nncontainer for my engine. The definition in nnc.h is kind of "generic" i.e. with the same set of entries, a contemporary stockfish nnue network definition should also be possible. Minus the compression part, which I do not particularly like.
// nnc.h
#pragma once
// Container layout (same for big and small net)
#define NNCID_CONTAINER_MAGIC 0 // 0x01434e4e u32
#define NNCID_NET_TYPE 1 // 0=training net 1=integer net
#define NNCID_CONTAINER_GENERATION 2 // An indicator of how much training it got
#define NNCID_ARCHITECTURE 3 // To which kind of impl it fits.
#define NNCID_PIECE_ORDER_STRING 11 // Data: "PNBRQKpnbrqk"
#define NNCID_SQUARE_MAPPING_ID 12 // 0 = A1->H8 (0-63), 1 = A8->H1, etc.
#define NNCID_INPUT_LAYER_SIZE 13 // e.g. 768 "features"
#define NNCID_ACCUMULATOR_SIZE 14 // e.g. 1024 for a "big" and 256 for a "small"
#define NNCID_ACCUMULATOR_COUNT 15 // e.g. 2 for 2 perspectives (white,black)
#define NNCID_HL1_WEIGHT_COUNT 121
#define NNCID_HL1_BIAS_COUNT 122
#define NNCID_HL1_OUTPUT_DIVISOR 123
#define NNCID_HL1_WEIGHTS 124
#define NNCID_HL1_BIASES 125
#define NNCID_HL2_SIZE 130 // Number of neurons in this layer, e.g. 32
#define NNCID_HL2_WEIGHT_COUNT 131
#define NNCID_HL2_BIAS_COUNT 132
#define NNCID_HL2_OUTPUT_DIVISOR 133
#define NNCID_HL2_WEIGHTS 134
#define NNCID_HL2_BIASES 135
#define NNCID_OUT_SIZE 140 // Number of neurons in this layer, e.g. 1
#define NNCID_OUT_WEIGHT_COUNT 141
#define NNCID_OUT_BIAS_COUNT 142
#define NNCID_OUT_OUTPUT_DIVISOR 143
#define NNCID_OUT_WEIGHTS 144
#define NNCID_OUT_BIASES 145The code, which creats my "f32" based training network looks a bit like this:
static NNRange_t write_u32_scalar_to_container(NNContainer_t c, u16 id, u32 value) {
NNRange_t r;
r = NNBegin(c, NN_MAKE_DESCRIPTOR(id,NN_MAKE_FORMAT(NNA_NATURAL,NNT_U32, NNC_SCALAR)));
if (r.begin < r.end) {
*(u32*)r.begin = value;
r.end = (u8*)r.begin + sizeof(u32);
}
NNEnd(c,r);
return r;
}
static NNRange_t write_cstr_to_container(NNContainer_t c, u16 id, const char* value) {
NNRange_t r;
r = NNBegin(c, NN_MAKE_DESCRIPTOR(id,NN_MAKE_FORMAT(NNA_NATURAL,NNT_I8, NNC_VECTOR)));
u8* pos = r.begin;
for (; *value != '\0' && pos < (u8*)r.end; ++value, ++pos) {
*pos = *value;
}
r.end = pos;
NNEnd(c,r);
return r;
}
static i8 CreateTrainingNet(StringSlice_t path, u32 accu_size, u32 accu_count) {
// 1. Create the container at 'path'
NNContainer_t c = NULL;
if (!NNContainerInit(&c, 100ULL * 1024ULL * 1024ULL)) {
return 0;
}
NNRange_t r;
// 2. Add Meta Tags (Gen 0, Arch 1, Type 0)
write_u32_scalar_to_container(c, NNCID_CONTAINER_MAGIC, 0x01434e4eUL);
write_u32_scalar_to_container(c, NNCID_NET_TYPE,0); // training net.
write_u32_scalar_to_container(c, NNCID_CONTAINER_GENERATION, 0); // newborn!
write_u32_scalar_to_container(c, NNCID_ARCHITECTURE, 1); // 1 being what we have right now - until it breaks then inc.
write_cstr_to_container(c, NNCID_PIECE_ORDER_STRING, "PNBRQKpnbrqk");
write_u32_scalar_to_container(c, NNCID_SQUARE_MAPPING_ID, 0);
u32 feature_count = 768UL;
write_u32_scalar_to_container(c, NNCID_INPUT_LAYER_SIZE, feature_count);
write_u32_scalar_to_container(c, NNCID_ACCUMULATOR_SIZE, accu_size);
write_u32_scalar_to_container(c, NNCID_ACCUMULATOR_COUNT, accu_count);
u32 hl1_weight_count = feature_count * accu_size;
write_u32_scalar_to_container(c, NNCID_HL1_WEIGHT_COUNT, hl1_weight_count);
write_u32_scalar_to_container(c, NNCID_HL1_BIAS_COUNT, accu_size);
write_u32_scalar_to_container(c, NNCID_HL1_OUTPUT_DIVISOR, 64); // our first best guess - the trainer might change this.
{
r = NNBegin(c, NN_MAKE_DESCRIPTOR(NNCID_HL1_WEIGHTS, NN_MAKE_FORMAT(NNA_64,NNT_F32, NNC_VECTOR)));
f32* pos = r.begin;
f32* end = pos + hl1_weight_count;
assert((u8*)end < (u8*)r.end);
for (; pos < end; ++pos) {
*pos = 0.0F;
}
r.end = (void*)end;
NNEnd(c, r);
}
{
r = NNBegin(c, NN_MAKE_DESCRIPTOR(NNCID_HL1_BIASES, NN_MAKE_FORMAT(NNA_64,NNT_F32, NNC_VECTOR)));
f32* pos = r.begin;
f32* end = pos + accu_size;
assert((u8*)end < (u8*)r.end);
for (; pos < end; ++pos) {
*pos = 0.0F;
}
r.end = (void*)end;
NNEnd(c, r);
}
u32 hl2_size = 32UL;
write_u32_scalar_to_container(c, NNCID_HL2_SIZE, hl2_size);
u32 hl2_weight_count = hl2_size * accu_size * accu_count;
write_u32_scalar_to_container(c, NNCID_HL2_WEIGHT_COUNT, hl2_weight_count);
write_u32_scalar_to_container(c, NNCID_HL2_BIAS_COUNT, hl2_size);
write_u32_scalar_to_container(c, NNCID_HL2_OUTPUT_DIVISOR, 64); // our first best guess
{
r = NNBegin(c, NN_MAKE_DESCRIPTOR(NNCID_HL2_WEIGHTS, NN_MAKE_FORMAT(NNA_64,NNT_F32, NNC_VECTOR)));
f32* pos = r.begin;
f32* end = pos + hl2_weight_count;
assert((u8*)end < (u8*)r.end);
for (; pos < end; ++pos) {
*pos = 0.0F;
}
r.end = (void*)end;
NNEnd(c, r);
}
{
r = NNBegin(c, NN_MAKE_DESCRIPTOR(NNCID_HL2_BIASES, NN_MAKE_FORMAT(NNA_64,NNT_F32, NNC_VECTOR)));
f32* pos = r.begin;
f32* end = pos + hl2_size;
assert((u8*)end < (u8*)r.end);
for (; pos < end; ++pos) {
*pos = 0.0F;
}
r.end = (void*)end;
NNEnd(c, r);
}
u32 out_size = 1UL;
write_u32_scalar_to_container(c, NNCID_OUT_SIZE, out_size);
u32 out_weight_count = hl2_size * out_size;
write_u32_scalar_to_container(c, NNCID_OUT_WEIGHT_COUNT, out_weight_count);
write_u32_scalar_to_container(c, NNCID_OUT_BIAS_COUNT, out_size);
write_u32_scalar_to_container(c, NNCID_OUT_OUTPUT_DIVISOR, 1);
{
r = NNBegin(c, NN_MAKE_DESCRIPTOR(NNCID_OUT_WEIGHTS, NN_MAKE_FORMAT(NNA_64,NNT_F32, NNC_VECTOR)));
f32* pos = r.begin;
f32* end = pos + out_weight_count;
assert((u8*)end < (u8*)r.end);
for (; pos < end; ++pos) {
*pos = 0.0F;
}
r.end = (void*)end;
NNEnd(c, r);
}
{
r = NNBegin(c, NN_MAKE_DESCRIPTOR(NNCID_OUT_BIASES, NN_MAKE_FORMAT(NNA_64,NNT_F32, NNC_VECTOR)));
f32* pos = r.begin;
f32* end = pos + out_size;
assert((u8*)end < (u8*)r.end);
for (; pos < end; ++pos) {
*pos = 0.0F;
}
r.end = (void*)end;
NNEnd(c, r);
}
if (!NNContainerSave(c,path.begin)) {
goto fail;
}
NNContainerTerm(c);
return 1;
fail:
if (NULL != c) {
NNContainerTerm(c);
c = NULL;
}
return 0;
}As you can see, there is not much work involved but you get all the "implicit" information, you need to make sense of the resulting .nnc file.
Consider this project my attempt to make the world of chess programming a better place.
My motivation to create nncontainer stems from my attempts to integrate NNUE into the chess engine, I currently develop. And the situation I found was dire. Not even AI (Gemini) is able to produce correct loading code in the current .nnue situation. No shortcuts, no sufficient documentation, no "contracts", about what can change in the next iteration. No way to inspect if a .nnue file fits to the implementation in the engine using it.
nncontainer might also be found useful by other people outside the chess programming community.
As usual, this code comes without any warranties and it is published in the very liberal BSD License.
I would be very happy and proud, if this got adopted - or at least triggered a shift towards a more professional approach to .nnue files, even if it is not my nncontainer but something else.
Cheers!