Flatdata

repository·master·Indexed 41 days ago

https://github.com/heremaps/flatdata

A library for creating efficient, read-only, memory-mappable binary datasets using a schema-driven approach. It includes the flatdata-generator (v0.4.12) to produce high-performance readers and builders for C++, Rust, and Python, as well as tools for archive inspection and JSON-to-binary conversion.

Tokens
25K
Snippets
75
Records
110
Agent score
52%

What's inside Flatdata

  1. Overview of flatdata

    master

    flatdata is a library designed for creating, storing, and accessing packed, memory-mappable binary structures with minimal overhead. It is optimized for large, read-only datasets where zero-overhead random access is required.

    Key features include:

    • Zero overhead random access via memory-mapped storage.
    • Support for bit and byte packing.
    • Schema-driven data structuring.
    • Portability across multiple languages.

    Limitations to note:

    • It does not support backwards compatible schema evolution.
    • It does not support mutable datasets.
    • It does not provide portable floating point serialization.
  2. What is flatdata-rs?

    master

    flatdata-rs is a library for creating, storing, and accessing packed, memory-mappable binary structures with minimal overhead.

    Key Features:

    • Schema-driven: Define data formats using a simple schema language supporting plain structs, vectors, and multivectors.
    • Zero-overhead random access: Data is serialized for portable, memory-mapped storage. This allows the operating system to handle loading, caching, and paging, enabling you to access data as if it were directly in memory.
    • Code Generation: Automatically generates specialized builders (for serialization) and readers (for deserialization) based on your schema.
  3. Explore Flatdata usage examples

    master

    The examples/ directory provides practical demonstrations of how to use the flatdata serialization and deserialization APIs. It covers various resource types and data transformation patterns, specifically focusing on:

    • Binary Layouts: Demonstrating how non-aligned data structures (e.g., a 23-bit structure) are written to a flatdata vector, including how the library handles structure alignment and padding.
    • Nested to Flat Conversion: Using a graph of coappearances (from JSON) as a case study to show how to convert nested data formats into a flat representation.
    • Advanced Data Representations: Demonstrating the use of all available flatdata data structures, representing ranges using sentinels, and storing strings as raw blocks.
  4. Simplify Index Ranges with @range

    master

    When storing index ranges (where one vector item points to the start of a range in another vector), you can use the @range annotation to simplify access.

    Without @range: You must manually slice using the sentinel value: edges.slice(nodes[i].first_edge_ref..nodes[i + 1].first_edge_ref)

    With @range: Annotate the field in the structure:

    struct Node {
        @range(edges_range)
        first_edge_ref : u32;
    }

    This adds an edges_range attribute to the node, allowing you to retrieve edges easily: edges.slice(nodes[i].edges_range)

    struct Node {
        ...
        @range(edges_range)
        first_edge_ref : u32;
    }
    
    // Usage:
    edges.slice(nodes[i].edges_range)
  5. How flatdata achieves high performance

    master

    Performance and Storage Principles

    flatdata achieves efficiency through several core technical strategies:

    • Bit-Level Storage: It allows operating at the bit level to minimize footprint. For example, boolean attributes use single bits rather than full bytes, and small integer ranges can be packed into spare bits of existing fields.
    • Zero-Copy Access: No data is accessed or copied until the moment it is explicitly used. When you access a field, only the necessary bytes are read.
    • Memory-Mapped Files: It leverages memory-mapped files and the OS page cache to manage data loading and access efficiently.
    • Platform Independence: Data is stored in little-endian order using a custom platform-independent structure alignment (one byte).
    • Efficient Implementation: The core reading and writing logic is implemented in C++ using templates and inlining to ensure minimal instructions per data access.
  6. Optimize data access performance in flatdata-py

    master

    The performance of flatdata-py depends heavily on the access pattern used.

    Iteration (Slow for bulk operations)

    Iterating over a vector (e.g., for x in archive.links) yields one Python object per element. This incurs significant per-element overhead because each field access must unpack bits from the memory-mapped data.

    Vectorized Access (Fast for bulk operations)

    For high-performance bulk operations, use vectorized methods that read fields directly into NumPy arrays or Pandas DataFrames. This avoids the per-element Python object overhead.

    Best Practices:

    • Single column access: Use vector.field_name to get a Pandas DataFrame of a specific field. This is most efficient when you only need one or a few fields.
    • Full row access: Use vector.to_numpy() or vector.to_data_frame() when you need all fields in a vector at once.
    • Slicing: Slices work with vectorized methods (e.g., vector[1000:2000].to_numpy()).
    • Random access: Use vector[i].field for accessing individual elements by index.

    Note: The underlying data is memory-mapped; the OS pages it from disk on demand. Vectorized results are materialized as NumPy arrays in RAM.

    # SLOW: Iterating over a vector
    count = sum(1 for x in archive.links if x.speed_limit > 100)
    
    # FAST: Single column access (returns a pandas DataFrame)
    df = archive.links.speed_limit
    count = len(df[df['speed_limit'] > 100])
    
    # FAST: Full NumPy structured array
    arr = archive.links.to_numpy()
    count = int(np.sum(arr['speed_limit'] > 100))
    
    # FAST: Slices work too
    arr = archive.links[1000:2000].to_numpy()
    df = archive.links[::10].to_data_frame()
  7. Use Decorations to extend schema entities

    master

    Decorations are first-class citizens in the schema and are validated during archive opening. They declare additional properties for entities.

    Common Decorations:

    • @optional: Applied to resources. If the resource is missing, the archive can still be opened.
    • @optional(<name>): Applied to fields. Marks a specific constant value as a sentinel (the none value), making the field optional in many language backends.
    • @const(<name>): Applied to fields to indicate which constant can appear there.
    • @explicit_reference(source_field, target_resource): Declares a relationship similar to a Foreign Key between a field and a resource.
    • @bound_implicitly(resource_a, resource_b): Groups multiple resources together to represent a single entity (useful for data locality).
    • @range(name_of_range_attribute): Simplifies retrieving slices of data from a vector based on an index attribute in a preceding vector.
  8. Understanding the use cases for flatdata

    master

    When to use flatdata

    flatdata is designed for high-performance data access patterns where data is read much more frequently than it is updated. It is most effective when:

    • Infrequent Updates: Your data updates infrequently (e.g., every few hours), and the update rate is significantly lower than the time required for serialization.
    • Full Rebuilds Allowed: You can afford to recreate the entire flatdata archive whenever a structural update is required.
    • RAM-Compatible Data Size: Your data can be serialized into a mid-sized archive (e.g., 50GB to 200GB) that is comparable to the available RAM on your machine.
    • High Read Volume: Your data is accessed many times, substantially more often than it is updated.
    • Cache Optimization: You want to optimize for cache-friendliness by sorting data, minimizing index sizes, grouping related data, and separating infrequent data.

    Ideal Data Types

    While flatdata does not impose a specific structure, it is optimized for:

    • Structures containing categorical and numerical data.
    • Data with implicit or explicit references (one-to-one, one-to-many, many-to-many) implemented via associative vectors.
    • Low-frequency attributes that only apply to a small subset of entities.
    • Arbitrary metadata attached to entities.
  9. Generating modules for supported languages

    master

    flatdata uses a generator that consumes a schema file and produces a module in your target language. The supported languages are categorized by their primary use case:

    First-class citizen implementations (Full support):

    • C++: The primary implementation, receiving features first.
    • Rust: The newest first-class implementation.

    Read-only implementations:

    • Python: Primarily used for inspecting data.
    • Dot: Used to generate schema diagrams.
  10. Generate Rust code from a flatdata schema using build.rs

    master

    To automate the generation of builders and readers from your .flatdata schema, use a build.rs script. This ensures that your Rust code stays in sync with your schema definition. You use the flatdata::generate function, providing the path to your schema file and the output directory (OUT_DIR).

    // build.rs
    fn main() {
        flatdata::generate(
            "path/to/my_schema.flatdata",
            &std::env::var("OUT_DIR").unwrap(),
        )
        .expect("generator failed");
    }
  11. Generate a Python module from a flatdata schema

    master

    After creating a flatdata schema file, use the flatdata-generator CLI to generate a Python module. This module allows you to read your existing flatdata archive using Pythonic interfaces.

    Use the --gen py flag to specify Python generation, provide your --schema file, and specify the destination with --output-file.

    flatdata-generator --gen py --schema locations.flatdata --output-file locations.py
  12. Handle multi-file schemas for C++ and Rust

    master

    When your schema uses import statements, you must generate each file separately. Imported types are referenced via include/import directives in the generated code rather than being re-emitted in the main file.

    For C++:

    1. Generate the shared types first: flatdata-generator -s schema/types.flatdata -g cpp -O schema/types.h
    2. Generate the main schema (which will #include "types.h"): flatdata-generator -s schema/main.flatdata -g cpp -O schema/main.h

    For Rust: Follow the same approach. Each imported file becomes its own module with pub use re-exports connecting the namespaces.

    Note for Python and Dot: Python and Dot generators emit all types monolithically. You do not need to generate imported files separately; all definitions are included in the single output file.

    # Generate shared types
    flatdata-generator -s schema/types.flatdata -g cpp -O schema/types.h
    
    # Generate main schema (will #include "types.h")
    flatdata-generator -s schema/main.flatdata -g cpp -O schema/main.h