yum-mirror/slang

Making it easier to work with shaders

git clone https://git.yummers.dev/yum-mirror/slang

Theresa FoleyAdd a memory-mappable binary serialization format (#7222)ec7ab914f

master
14.4 KiB258 linesraw

Serialization

Slang's infrastructure for serialization is currently in flux, so there exist a mixture of different subsystems, using a mixture of different techniques.

This document is curently minimal, and primarily serves to provide a replacement for an older draft that no longer reflects the state of the codebase.

The Fossil Format

The "fossil" format is a memory-mappable binary format for general-purpose serialization.

Goals

The main goals of the fossil format are:

  • Data can be read from memory as-is.

    • Basic types are stored at offsets that are naturally aligned (e.g., a 4-byte integer is 4-byte aligned)

    • Pointers are encoded as relative offsets, and can be traversed without any "relocation" step after data is loaded.

  • Supports general-purpose data, including complicated object graphs.

  • Data can include embedded layout information, allowing code to traverse it without statically knowing the structure.

    • Embedded layout information should support versioning; new code should be able to load old data by notcing what has/hasn't been encoded.
  • Layout information is optional, and data can be traversed with minimal overhead by code that knows/assumes the layout

Top-Level Structure

A serialized blob in fossil format starts with a header (see Slang::Fossil::Header), which in turn points to the root value. All other data in the blob should be reachable from the root value, and an application can choose to make the root value whatever type they want (an array, structure, etc.).

Encoding

Endian

All data is read/written in the endianness of the host machine. There is currently no automatic support for encoding endian-ness as part of the format; a byte-order mark should be added if we ever need to support big-endian platforms.

Fixed-Size Types

Basic Types

Basic types like fixed-width integers and floating-point numbers are encoded as-is. That is, an N-byte value is stored directly as N bytes of data with N-byte alignment.

A Boolean value is encoded as an 8-bit unsigned integer holding either zero or one.

Pointers

A pointer is encoded as a 4-byte signed integer, representing a relative offset.

If the relative offset value is zero, then the pointer is null. Otehrwise, the relative offset value should be added to the offset of the pointer itself, to get the offset of the target.

Optionals

An optional value of some type T (e.g., the equivalent of a std::optional<T>) is encoded as a pointer to a T. If the pointer is null, the optional has no value; otherwise the value is stored at the offset being pointed to.

Note that when encoding a pointer to an optional (std::optional<T> *) or an optional pointer (std::optional<T*>), there will be two indirections.

Records

Things that are conceptually like a struct or tuple are encoded as records, which are simply a sequence of fields.

The alignment of a record is the maximum alignment of its fields.

Fields in a record are laid out sequentially, where each field gets the next suitably-aligned offset after the preceding field. No effort is made to fill in "gaps" left by preceding fields.

Note: currently the size of a record is not rounded up to be a multiple of its alignment, so it is possible for one field to be laid out in the "tail padding" of the field before it. This behavior should probably be changed, so that the fossilized layout better matches what C/C++ compilers tend to do.

Variable-Size Types

Types where different instances may consume a different number of bytes may be encoded either inline or indirectly.

If a variable-size type V is being referred to by a pointer or optional (e.g., V* or std::optional<V>), then it will be encoded inline as the target address of that pointer/optional.

In all other contexts, including when a V is used as a field or a record, it will be encoded indirectly (conceptually, as if the field was actually a V*). When a variable-size type is encoded indirectly, a null pointer should be interpreted as an empty instance of the type V.

Arrays

An array of T is encoded as a sequence of T values, separated by the stride of T (the size of T rounded up to the alignment of T). The offset of the array is the offset of its first element.

The number of elements in the array is encoded as a 4-byte unsigned integer stored immediately before the offset of the array itself.

Strings

A string is encoded in the same way that an array of 8-bit bytes would be (including the count stored before the first element). The only additional detail is that the serialized data must include an additional nul byte after the last element of the string.

The data of a string is assumed to be in UTF-8 encoding, but there is nothing about the format that validates or enforces this.

Dictionaries

A dictionary with keys of type K and values of type V is encoded in the same way as an array of P, where P is a two-element tuple of a K and a V.

There is currently no provision made for efficient lookup of elements of a fossilized dictionary.

Variants

A variant is a fossilized value that can describe its own layout.

The content of variant holding a value of type T is encoded exactly as a record with one field of type T would be, starting at the offset of the variant itself.

The four bytes immediately preceding a variant store a relative pointer to the fossilized layout for the type T of the content.

Layouts

Every layout starts with a 4-byte unsigned integer that holds a tag representing the kind of layout (see Slang::FossilizedValKind). The value of the tag determines what, if any, information appears after the tag.

In any place where a relative pointer to a layout is expected, a null pointer may be used to indicate that the relevant layout information is either unknown, or was elided from the fossilized data.

Pointer-Like Types

For pointers (T*) and optionals (Optional<T>), the tag is followed by a relative pointer to a layout for T.

Container Types

For arrays and dictionaries, the tag is followed by:

  • A relative pointer to a layout for the element type

  • A 4-byte unsigned integer holding the stride between elements

Record Types

For records, the tag is followed by:

  • A 4-byte unsigned integer holding the number of fields, N

  • N 8-byte values representing the fields, each comprising:

    • A relative pointer to the type of the field

    • A 4-byte unsigned integer holding the offset of that field within the record

The RIFF Support Code

There is code in source/core/slang-riff.{h,cpp} that implements abastractions for reading and writing RIFF-structured files.

The current RIFF implementation is trying to be "correct" for the RIFF format as used elsewhere (e.g., for .wav files), but it is unclear if this choice is actually helping us rather than hurting us. It is likely that we will want to customize the format if we keep using (e.g., at the very least increase the minimum alignment of chunks).

RIFF is a simple chunk-based file format that is used by things like WAV files, and has inspired many similar container formats used in media/games.

The RIFF structures are currently being used for a few things:

  • The top-level structure of serialized files for slang modules, "module libraries". This design choice is being utilized so that the compiler can navigate the relevant structures and extract the parts it needs (e.g., just the digest of a module, but not the AST or IR).

  • Repro files are using a top-level RIFF container, but it is just to encapsulate a single blob of raw data (with internal offset-based pointers)

  • The structure of the IR and SourceLoc serialization formats uses RIFF chunks for their top-level structure, but doesn't really make use of the ability to navigate them in memory or perform random access.

  • The actual serialized AST format is currently a deep hierarchy of RIFF chunks.

  • There is also code for a RIFF-based hierarchical virtual file-system format, and that format is being used for the serialized core module (seemingly just because it includes support for LZ4; the actual "file system" that gets serialized seems to only have a single file in it).

General-Purpose Hierarchical Data Serialization

The code in source/slang/slang-serialize.{h,cpp} implements a framework for serialization that is intended to be lightweight for users to adopt, while also scaling to more complicated cases like our AST serialization.

In the simplest cases, all a programmer needs to know is that if they have declared a type like:

struct MyThing
{
    float f;
    List<OtherThing> others;
    SomeObject* obj;
};

then they can add serialization support for their type by writing a function like:

void serialize(Serializer const& serializer, MyThing& value)
{
    SLANG_SCOPED_SERIALIZER_STRUCT(serializer);
    serialize(serializer, value.f);
    serialize(serializer, value.others);
    serialize(serializer, value.obj);
}

If the OtherThing and SomeObject types were already set up with their own serialization support, then that should be all that's needed. Of course there's a lot more to it in once you get into the details and the difficult cases. For now, looking at source/slang/slang-serialize.h is probably the best way to learn more about the approach.

One key goal of this serialization system is that it allows the serialized format to be swapped in and out without affecting the per-type serialize functions. Currently there are only a small number of implementations.

RIFF Serialization

The files slang-serialize-riff.{h,cpp} provide an implementation of the general-purpose serialization framework that reads/writes RIFF files with a particular kind of structure, based on what had previously been hard-coded for use in serializing the AST to RIFF.

In practice this representation is kind of like an encoding of JSON as RIFF chunks, with leaf/data chunks for what would be leaf values in JSON, and container chunks for arrays and dictionaries (plus other aggregates that would translate into arrays or dictionaries in JSON).

Fossil Serialization

The files slang-serialize-fossil.{h,cpp} provide an implementation of the generla-purpose serialization framwork that reads/writes the "fossil" format, which is described earlier in this document.

AST Serialization

AST serialization is implementation as an application of the general-purpose framework described above. There is an ASTSerializer type that expands on Serializer to include the additional context that is needed for handling AST-related types like SourceLoc, Name, and the NodeBase hierarchy.

The Old Serialization System

The old serialization system has largely been removed, but some vestiges of it are still noticeable.

There was an older serialization system in place that made use of an extensive RTTI system that types had to be registered with, plus a set of boilerplate macros for interfacing with that system that were generated from the C++ declarations of the AST node types. That system was also predicated on the idea that to serialize a user C++ type Foo, one would also hand-author a matching C++ type SerialFooData, and then write code to translate a Foo to/from a SerialFooData plus code to read/write a SerialFooData from the actual serialized data format.

The IR and SourceLoc serialization approaches are currently still heavily influenced by the old serialization system, and there are still vestigates of the RTTI infrastructure that was introduced to support it. The hope is that as more subsystems are ported to use newer approaches to serialization, this code can all be eliminated.

The following sections are older text that describes some of the formats that have not yet been revisited.

IR Serialization

This mechanism is much simpler than generali serialization, because by design the IR types are very homogeneous in style. There are a few special cases, but in general an instruction consists of

  • Its type
  • A SourceLoc
  • 0 or more operands.
  • 0 or more children.

Within the IR instructions are pointers to IRInst derived types. As previously discussed serializing pointers directly is generally not a good idea. To work around this the pointers are turned into 32 bit indices. Additionally we know that an instruction can belong to at most one other instruction.

When serializing out special handling is made for child instructions - their indices are made to be a contiguous range of indices for all instructions that belong to each parent. The indices are ordered into the same order as the children are held in the parent. By using this mechanism it is not necessary to directly save off the indices that belong to a parent, only the range of indices.

The actual serialization mechanism is similar to the generalized mechanism - referenced objects are saved off in order of their indices. What is different is that the encoding fixes the size of the Inst to IRSerialData. That this can hold up to two operands, if the instruction has more than two operands then one of the UInt32 is the operand count and the other is an offset to a list of operands. It probably makes sense to alter this in the future to stream the instructions payload directly.

IR serialization allows a simple compression mechanism, that works because much of the IR serialized data is UInt32 data, that can use a variable byte encoding.

SourceLoc Serialization

SourceLoc serialization presents several problems. Firstly we have two distinct serialization mechanisms that need to use it - IR serialization and generalized serialization. That being the case it cannot be saved directly in either, even though it may be referenced by either.

To keep things simple for now we build up SourceLoc information for both IR and general serialization via their writers adding their information into a SerialSourceLocWriter. Then we can save this information into a RIFF section, that can be loaded before either general or IR deserialization is used.

When reading the SourceLoc information has to be located and deserialized before any AST or IR deserialization. The SourceLoc data can then be turned into a SerialSourceLocReader, which is then either set on the SerialReaders SerialExtraObjects. Or passed to the IRSerialReader.

1Serialization
2=============
3
4Slang's infrastructure for serialization is currently in flux, so there exist a mixture of different subsystems, using a mixture of different techniques.
5
6This document is curently minimal, and primarily serves to provide a replacement for an older draft that no longer reflects the state of the codebase.
7
8The Fossil Format
9=================
10
11The "fossil" format is a memory-mappable binary format for general-purpose serialization.
12
13Goals
14-----
15
16The main goals of the fossil format are:
17
18* Data can be read from memory as-is.
19
20  * Basic types are stored at offsets that are naturally aligned (e.g., a 4-byte integer is 4-byte aligned)
21
22  * Pointers are encoded as relative offsets, and can be traversed without any "relocation" step after data is loaded.
23
24* Supports general-purpose data, including complicated object graphs.
25
26* Data can include embedded layout information, allowing code to traverse it without statically knowing the structure.
27
28  * Embedded layout information should support versioning; new code should be able to load old data by notcing what has/hasn't been encoded.
29
30* Layout information is *optional*, and data can be traversed with minimal overhead by code that knows/assumes the layout
31
32Top-Level Structure
33-------------------
34
35A serialized blob in fossil format starts with a header (see `Slang::Fossil::Header`), which in turn points to the *root value*.
36All other data in the blob should be reachable from the root value, and an application can choose to make the root value whatever type they want (an array, structure, etc.).
37
38Encoding
39--------
40
41### Endian
42
43All data is read/written in the endianness of the host machine.
44There is currently no automatic support for encoding endian-ness as part of the format; a byte-order mark should be added if we ever need to support big-endian platforms.
45
46### Fixed-Size Types
47
48#### Basic Types
49
50Basic types like fixed-width integers and floating-point numbers are encoded as-is.
51That is, an N-byte value is stored directly as N bytes of data with N-byte alignment.
52
53A Boolean value is encoded as an 8-bit unsigned integer holding either zero or one.
54
55#### Pointers
56
57A pointer is encoded as a 4-byte signed integer, representing a relative offset.
58
59If the relative offset value is zero, then the pointer is null.
60Otehrwise, the relative offset value should be added to the offset of the pointer itself, to get the offset of the target.
61
62#### Optionals
63
64An optional value of some type `T` (e.g., the equivalent of a `std::optional<T>`) is encoded as a pointer to a `T`.
65If the pointer is null, the optional has no value; otherwise the value is stored at the offset being pointed to.
66
67Note that when encoding a pointer to an optional (`std::optional<T> *`) or an optional pointer (`std::optional<T*>`), there will be two indirections.
68
69#### Records
70
71Things that are conceptually like a `struct` or tuple are encoded as *records*, which are simply a sequence of *fields*.
72
73The alignment of a record is the maximum alignment of its fields.
74
75Fields in a record are laid out sequentially, where each field gets the next suitably-aligned offset after the preceding field.
76No effort is made to fill in "gaps" left by preceding fields.
77
78Note: currently the size of a record is *not* rounded up to be a multiple of its alignment, so it is possible for one field to be laid out in the "tail padding" of the field before it.
79This behavior should probably be changed, so that the fossilized layout better matches what C/C++ compilers tend to do.
80
81### Variable-Size Types
82
83Types where different instances may consume a different number of bytes may be encoded either *inline* or *indirectly*.
84
85If a variable-size type `V` is being referred to by a pointer or optional (e.g., `V*` or `std::optional<V>`), then it will be encoded inline as the target address of that pointer/optional.
86
87In all other contexts, including when a `V` is used as a field or a record, it will be encoded indirectly (conceptually, as if the field was actually a `V*`).
88When a variable-size type is encoded indirectly, a null pointer should be interpreted as an empty instance of the type `V`.
89
90#### Arrays
91
92An array of `T` is encoded as a sequence of `T` values, separated by the *stride* of `T` (the size of `T` rounded up to the alignment of `T`).
93The offset of the array is the offset of its first element.
94
95The number of elements in the array is encoded as a 4-byte unsigned integer stored immediately *before* the offset of the array itself.
96
97#### Strings
98
99A string is encoded in the same way that an array of 8-bit bytes would be (including the count stored before the first element).
100The only additional detail is that the serialized data *must* include an additional nul byte after the last element of the string.
101
102The data of a string is assumed to be in UTF-8 encoding, but there is nothing about the format that validates or enforces this.
103
104#### Dictionaries
105
106A dictionary with keys of type `K` and values of type `V` is encoded in the same way as an array of `P`, where `P` is a two-element tuple of a `K` and a `V`.
107
108There is currently no provision made for efficient lookup of elements of a fossilized dictionary.
109
110#### Variants
111
112A *variant* is a fossilized value that can describe its own layout.
113
114The content of variant holding a value of type `T` is encoded exactly as a record with one field of type `T` would be, starting at the offset of the variant itself.
115
116The four bytes immediately preceding a variant store a relative pointer to the fossilized layout for the type `T` of the content.
117
118### Layouts
119
120Every layout starts with a 4-byte unsigned integer that holds a tag representing the kind of layout (see `Slang::FossilizedValKind`).
121The value of the tag determines what, if any, information appears after the tag.
122
123In any place where a relative pointer to a layout is expected, a null pointer may be used to indicate that the relevant layout information is either unknown, or was elided from the fossilized data.
124
125#### Pointer-Like Types
126
127For pointers (`T*`) and optionals (`Optional<T>`), the tag is followed by a relative pointer to a layout for `T`.
128
129#### Container Types
130
131For arrays and dictionaries, the tag is followed by:
132
133* A relative pointer to a layout for the element type
134
135* A 4-byte unsigned integer holding the stride between elements
136
137#### Record Types
138
139For records, the tag is followed by:
140
141* A 4-byte unsigned integer holding the number of fields, `N`
142
143* `N` 8-byte values representing the fields, each comprising:
144
145    * A relative pointer to the type of the field
146
147    * A 4-byte unsigned integer holding the offset of that field within the record
148
149The RIFF Support Code
150=====================
151
152There is code in `source/core/slang-riff.{h,cpp}` that implements abastractions for reading and writing RIFF-structured files.
153
154The current RIFF implementation is trying to be "correct" for the RIFF format as used elsewhere (e.g., for `.wav` files), but it is unclear if this choice is actually helping us rather than hurting us.
155It is likely that we will want to customize the format if we keep using (e.g., at the very least increase the minimum alignment of chunks).
156
157RIFF is a simple chunk-based file format that is used by things like WAV files, and has inspired many similar container formats used in media/games.
158
159The RIFF structures are currently being used for a few things:
160
161* The top-level structure of serialized files for slang modules, "module libraries". This design choice is being utilized so that the compiler can navigate the relevant structures and extract the parts it needs (e.g., just the digest of a module, but not the AST or IR).
162
163* Repro files are using a top-level RIFF container, but it is just to encapsulate a single blob of raw data (with internal offset-based pointers)
164
165* The structure of the IR and `SourceLoc` serialization formats uses RIFF chunks for their top-level structure, but doesn't really make use of the ability to navigate them in memory or perform random access.
166
167* The actual serialized AST format is currently a deep hierarchy of RIFF chunks.
168
169* There is also code for a RIFF-based hierarchical virtual file-system format, and that format is being used for the serialized core module (seemingly just because it includes support for LZ4; the actual "file system" that gets serialized seems to only have a single file in it).
170
171General-Purpose Hierarchical Data Serialization
172===============================================
173
174The code in `source/slang/slang-serialize.{h,cpp}` implements a framework for serialization that is intended to be lightweight for users to adopt, while also scaling to more complicated cases like our AST serialization.
175
176In the simplest cases, all a programmer needs to know is that if they have declared a type like:
177
178    struct MyThing
179    {
180        float f;
181        List<OtherThing> others;
182        SomeObject* obj;
183    };
184
185then they can add serialization support for their type by writing a function like:
186
187    void serialize(Serializer const& serializer, MyThing& value)
188    {
189        SLANG_SCOPED_SERIALIZER_STRUCT(serializer);
190        serialize(serializer, value.f);
191        serialize(serializer, value.others);
192        serialize(serializer, value.obj);
193    }
194
195If the `OtherThing` and `SomeObject` types were already set up with their own serialization support, then that should be all that's needed.
196Of course there's a lot more to it in once you get into the details and the difficult cases.
197For now, looking at `source/slang/slang-serialize.h` is probably the best way to learn more about the approach.
198
199One key goal of this serialization system is that it allows the serialized format to be swapped in and out without affecting the per-type `serialize` functions.
200Currently there are only a small number of implementations.
201
202RIFF Serialization
203------------------
204
205The files `slang-serialize-riff.{h,cpp}` provide an implementation of the general-purpose serialization framework that reads/writes RIFF files with a particular kind of structure, based on what had previously been hard-coded for use in serializing the AST to RIFF.
206
207In practice this representation is kind of like an encoding of JSON as RIFF chunks, with leaf/data chunks for what would be leaf values in JSON, and container chunks for arrays and dictionaries (plus other aggregates that would translate into arrays or dictionaries in JSON).
208
209Fossil Serialization
210--------------------
211
212The files `slang-serialize-fossil.{h,cpp}` provide an implementation of the generla-purpose serialization framwork that reads/writes the "fossil" format, which is described earlier in this document.
213
214AST Serialization
215=================
216
217AST serialization is implementation as an application of the general-purpose framework described above.
218There is an `ASTSerializer` type that expands on `Serializer` to include the additional context that is needed for handling AST-related types like `SourceLoc`, `Name`, and the `NodeBase` hierarchy.
219
220The Old Serialization System
221============================
222
223The old serialization system has largely been removed, but some vestiges of it are still noticeable.
224
225There was an older serialization system in place that made use of an extensive RTTI system that types had to be registered with, plus a set of boilerplate macros for interfacing with that system that were generated from the C++ declarations of the AST node types.
226That system was also predicated on the idea that to serialize a user C++ type `Foo`, one would also hand-author a matching C++ type `SerialFooData`, and then write code to translate a `Foo` to/from a `SerialFooData` plus code to read/write a `SerialFooData` from the actual serialized data format.
227
228The IR and `SourceLoc` serialization approaches are currently still heavily influenced by the old serialization system, and there are still vestigates of the RTTI infrastructure that was introduced to support it.
229The hope is that as more subsystems are ported to use newer approaches to serialization, this code can all be eliminated.
230
231The following sections are older text that describes some of the formats that have not yet been revisited.
232
233IR Serialization
234----------------
235
236This mechanism is *much* simpler than generali serialization, because by design the IR types are very homogeneous in style. There are a few special cases, but in general an instruction consists of
237
238* Its type
239* A SourceLoc
240* 0 or more operands.
241* 0 or more children. 
242
243Within the IR instructions are pointers to IRInst derived types. As previously discussed serializing pointers directly is generally not a good idea. To work around this the pointers are turned into 32 bit indices. Additionally we know that an instruction can belong to at most one other instruction. 
244
245When serializing out special handling is made for child instructions - their indices are made to be a contiguous range of indices for all instructions that belong to each parent. The indices are ordered into the same order as the children are held in the parent. By using this mechanism it is not necessary to directly save off the indices that belong to a parent, only the range of indices. 
246
247The actual serialization mechanism is similar to the generalized mechanism - referenced objects are saved off in order of their indices. What is different is that the encoding fixes the size of the Inst to `IRSerialData`. That this can hold up to two operands, if the instruction has more than two operands then one of the UInt32 is the operand count and the other is an offset to a list of operands. It probably makes sense to alter this in the future to stream the instructions payload directly. 
248
249IR serialization allows a simple compression mechanism, that works because much of the IR serialized data is UInt32 data, that can use a variable byte encoding.
250
251SourceLoc Serialization
252-----------------------
253
254SourceLoc serialization presents several problems. Firstly we have two distinct serialization mechanisms that need to use it - IR serialization and generalized serialization. That being the case it cannot be saved directly in either, even though it may be referenced by either. 
255
256To keep things simple for now we build up SourceLoc information for both IR and general serialization via their writers adding their information into a SerialSourceLocWriter. Then we can save this information into a RIFF section, that can be loaded before either general or IR deserialization is used.  
257
258When reading the SourceLoc information has to be located and deserialized before any AST or IR deserialization. The SourceLoc data can then be turned into a SerialSourceLocReader, which is then either set on the `SerialReaders` `SerialExtraObjects`. Or passed to the `IRSerialReader`.