processor | year | arithmetic logic units | SIMD units | simdjson |
---|---|---|---|---|
Apple M* | 2019 | 6+ | ||
Intel Lion Cove | 2024 | 6 | ||
AMD Zen 5 | 2024 | 6 |
The simdjson library is found in...
Start with JSON.
{"name":"Scooby", "age": 3, "friends":["Fred", "Daphne", "Velma"]}
Parses (everything) to Document-Object-Model:
Copies to user data structure.
"123"
as a number)--
Can load a multi-kilobyte file and only parse a narrow segment from an fast index.
#include <iostream>
#include "simdjson.h"
using namespace simdjson;
int main(void) {
ondemand::parser parser;
padded_string json = padded_string::load("twitter.json");
ondemand::document tweets = parser.iterate(json);
std::cout << uint64_t(tweets["search_metadata"]["count"]) << " results." << std::endl;
}
Imagine you're building a game server that needs to persist player data.
You start simple:
struct Player {
std::string username;
int level;
double health;
std::vector<std::string> inventory;
};
Without reflection, you may write this tedious code:
// Serialization - converting Player to JSON
fmt::format(
"{{"
"\"username\":\"{}\","
"\"level\":{},"
"\"health\":{},"
"\"inventory\":{}"
"}}",
escape_json(p.username),
p.level,
std::isfinite(p.health) ? p.health : -1.0,
p.inventory| std::views::transform(escape_json)
);
Or you might use a library.
std::string to_json(Player& p) {
return nlohmann::json{{"username", p.username},
{"level", p.level},
{"health", p.health},
{"inventory", p.inventory}}
.dump();
}
object obj = val.get_object();
p.username = obj["username"].get_string();
p.level = obj["level"].get_int64();
p.health = obj["health"].get_double();
array arr = obj["inventory"].get_array();
for (auto item : arr) {
p.inventory.emplace_back(item.get_string());
}
This manual approach has several problems:
struct Equipment {
std::string name;
int damage; int durability;
};
struct Achievement {
std::string title; std::string description; bool unlocked;
std::chrono::system_clock::time_point unlock_time;
};
struct Player {
std::string username;
int level; double health;
std::vector<std::string> inventory;
std::map<std::string, Equipment> equipped; // New!
std::vector<Achievement> achievements; // New!
std::optional<std::string> guild_name; // New!
};
Suddenly you need to write hundreds of lines of serialization code!
With C++26 reflection and simdjson, all that boilerplate disappears:
// Just define your struct - no extra code needed!
struct Player {
std::string username;
int level;
double health;
std::vector<std::string> inventory;
std::map<std::string, Equipment> equipped;
std::vector<Achievement> achievements;
std::optional<std::string> guild_name;
};
// Serialization - one line!
void save_player(const Player& p) {
std::string json = simdjson::to_json(p); // That's it!
// Save json to file...
}
// Deserialization - one line!
Player load_player(const std::string& json_str) {
return simdjson::from<Player>(json_str); // That's it!
}
# Python
import json
json_str = json.dumps(player.__dict__)
player = Player(**json.loads(json_str))
def inspect_object(obj):
print(f"Class name: {obj.__class__.__name__}")
for attr, value in vars(obj).items():
print(f" {attr}: {value}")
jsonData, err := json.MarshalIndent(player, "", " ")
if err != nil {
log.Fatalf("Error during serialization: %v", err)
}
var deserializedPlayer Player
err = json.Unmarshal([]byte(jsonStr), &deserializedPlayer)
typ := reflect.TypeOf(obj)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
}
string jsonString = JsonSerializer.Serialize(player, options);
Player deserializedPlayer = JsonSerializer.Deserialize<Player>(jsonInput, options);
Class<?> playerClass = Player.class;
Object playerInstance = playerClass.getDeclaredConstructor().newInstance();
Field nameField = playerClass.getDeclaredField("name");
// Rust with serde
let json_str = serde_json::to_string(&player)?;
let player: Player = serde_json::from_str(&json_str)?;
language | runtime reflection | compile-time reflection |
---|---|---|
C++ 26 | ||
Go | ||
Java | ||
C# | ||
Rust |
std::string json_str = simdjson::to_json(player);
Player player = simdjson::from<Player>(json_str);
"How can compile-time reflection handle runtime JSON data?"
The answer: Reflection operates on types and structure, not runtime values.
It generates regular C++ code at compile time that handles your runtime data.
// What you write:
Player p = simdjson::from<Player>(runtime_json_string);
// What reflection generates at COMPILE TIME (conceptually):
Player deserialize_Player(const json& j) {
Player p;
p.username = j["username"].get<std::string>();
p.level = j["level"].get<int>();
p.health = j["health"].get<double>();
p.inventory = j["inventory"].get<std::vector<std::string>>();
// ... etc for all members
return p;
}
template <typename T>
requires(std::is_class_v<T>) // For user-defined types
error_code deserialize(auto& json_value, T& out) {
simdjson::ondemand::object obj;
auto er = json_value.get_object().get(obj);
if(er) { return er; }
// capture the attributes:
constexpr auto members = std::define_static_array(std::meta::nonstatic_data_members_of(^^T,
std::meta::access_context::unchecked()));
// This for loop happens at COMPILE TIME
template for (constexpr auto member : members) {
// These are compile-time constants
constexpr std::string_view field_name = std::meta::identifier_of(member);
constexpr auto member_type = std::meta::type_of(member);
// This generates code for each member
auto err = obj[field_name].get(out.[:member:]);
if (err && err != simdjson::NO_SUCH_FIELD) {
return err;
}
};
return simdjson::SUCCESS;
}
The template for
statement is the key:
This means:
struct Player {
std::string username; // β Compile-time: reflection sees this
int level; // β Compile-time: reflection sees this
double health; // β Compile-time: reflection sees this
};
// COMPILE TIME: Reflection reads Player's structure and generates:
// - Code to read "username" as string
// - Code to read "level" as int
// - Code to read "health" as double
// RUNTIME: The generated code processes actual JSON data
std::string json = R"({"username":"Alice","level":42,"health":100.0})";
Player p = simdjson::from<Player>(json);
// Runtime values flow through compile-time generated code
//
COMPILE ERROR: Type mismatch detected
struct BadPlayer {
int username; // Oops, should be string!
};
// simdjson::from<BadPlayer>(json) won't compile if JSON has string
//
COMPILE ERROR: Non-serializable type
struct InvalidType {
std::thread t; // Threads can't be serialized!
};
// simdjson::to_json(InvalidType{}) fails at compile time
//
COMPILE SUCCESS: All types are serializable
struct GoodType {
std::vector<int> numbers;
std::map<std::string, double> scores;
std::optional<std::string> nickname;
};
// All standard containers work automatically!
Since reflection happens at compile time, there's no runtime penalty:
The generated code is often faster than hand-written code because:
You might think "automatic = slow", but with simdjson + reflection:
The generated code is often faster than hand-written code!
This pattern extends beyond games:
With C++26 reflection, C++ finally catches up to languages like Rust (serde), Go (encoding/json), and C# (System.Text.Json) in terms of ease of use, but with better performance thanks to simdjson's SIMD optimizations.
struct Meeting {
std::string title;
std::chrono::system_clock::time_point start_time;
std::vector<std::string> attendees;
std::optional<std::string> location;
bool is_recurring;
};
// Automatically serializable/deserializable!
std::string json = simdjson::to_json(Meeting{
.title = "CppCon Planning",
.start_time = std::chrono::system_clock::now(),
.attendees = {"Alice", "Bob", "Charlie"},
.location = "Denver",
.is_recurring = true
});
Meeting m = simdjson::from<Meeting>(json);
struct TodoItem {
std::string task;
bool completed;
std::optional<std::string> due_date;
};
struct TodoList {
std::string owner;
std::vector<TodoItem> items;
std::map<std::string, int> tags; // tag -> count
};
// Serialize complex nested structures
TodoList my_todos = { /* ... */ };
std::string json = simdjson::to_json(my_todos);
// Deserialize back - perfect round-trip
TodoList restored = simdjson::from<TodoList>(json);
assert(my_todos == restored); // Works if you define operator==
Just two functions. Infinite possibilities.
simdjson::to_json(object) // β JSON string
simdjson::from<T>(json) // β T object
That's it.
No macros. No code generation. No external tools.
Just simdjson leveraging C++26 reflection.
We can say that serializing/parsing the basic types and custom classes/structs is pretty much effortless.
How do we automatically serialize ALL these different containers?
std::vector<T>
, std::list<T>
, std::deque<T>
std::map<K,V>
, std::unordered_map<K,V>
std::set<T>
, std::array<T,N>
Without concepts, you'd need a separate function for EACH container type:
// The OLD way - repetitive and error-prone!
void serialize(string_builder& b, const std::vector<T>& v) { /* ... */ }
void serialize(string_builder& b, const std::list<T>& v) { /* ... */ }
void serialize(string_builder& b, const std::deque<T>& v) { /* ... */ }
void serialize(string_builder& b, const std::set<T>& v) { /* ... */ }
// ... 20+ more overloads for each container type!
Problem: New container type? Write more boilerplate!
Concepts let us say: "If it walks like a duck and quacks like a duck..."
// The NEW way - one function handles ALL array-like containers!
template<typename T>
requires(has_size_and_subscript<T>) // "If it has .size() and operator[]"
void serialize(string_builder& b, const T& container) {
b.append('[');
for (size_t i = 0; i < container.size(); ++i) {
serialize(b, container[i]);
}
b.append(']');
}
Works with
vector
, array
, deque
, custom containers...
When you write:
struct GameData {
std::vector<int> scores; // Array-like β [1,2,3]
std::map<string, Player> players; // Map-like β {"Alice": {...}}
MyCustomContainer<Item> items; // Your container β Just works!
};
The magic:
Write once, works everywhereβ’
A Zen 5 CPU and a Pentium 4 CPU can be quite different.
bool has_sse2() { /* query the CPU */ }
bool has_avx2() { /* query the CPU */ }
bool has_avx512() { /* query the CPU */ }
These functions cannot be consteval
.
using SumFunc = float (*)(const float *, size_t);
SumFunc &get_sum_fnc() {
static SumFunc sum_impl = sum_init;
return sum_impl;
}
We initialize it with some special initialization function.
float sum_init(const float *data, size_t n) {
SumFunc &sum_impl = get_sum_fnc();
if (has_avx2()) {
sum_impl = sum_avx2;
} else if (has_sse2()) {
sum_impl = sum_sse2;
} else {
sum_impl = sum_generic;
}
return sum_impl(data, n);
}
On first call, get_sum_fnc()
is modified, and then it will remain constant.
simple_needs_escaping(std::string_view v) {
for (unsigned char c : v) {
if(json_quotable_character[c]) { return true; }
}
return false;
}
__m128i word = _mm_loadu_si128(data); // load 16 bytes
// check for control characters:
_mm_cmpeq_epi8(_mm_subs_epu8(word, _mm_set1_epi8(31)),
_mm_setzero_si128());
__m512i word = _mm512_loadu_si512(data); // load 64 bytes
// check for control characters:
_mm512_cmple_epu8_mask(word, _mm512_set1_epi8(31));
fast_needs_escaping
without inlining prevents useful optimizations.-march=native
), use fancier tricks.3.4 GB/s - 14x faster than nlohmann, 2.5x faster than Serde!
What is Ablation?
From neuroscience: systematically remove parts to understand function
Our Approach:
(Baseline - Disabled) / Disabled
The Insight: JSON field names are known at compile time!
Traditional (Runtime):
// Every serialization call:
write_string("\"username\""); // Quote & escape at runtime
write_string("\"level\""); // Quote & escape again!
With Consteval (Compile-Time):
constexpr auto username_key = "\"username\":"; // Pre-computed!
b.append_literal(username_key); // Just memcpy!
Dataset | Baseline | No Consteval | Impact | Speedup |
---|---|---|---|---|
3,231 MB/s | 1,624 MB/s | -50% | 1.99x | |
CITM | 2,341 MB/s | 883 MB/s | -62% | 2.65x |
Twitter Example (100 tweets):
Result: 2-2.6x faster serialization!
The Problem: JSON requires escaping "
, \
, and control chars
Traditional (1 byte at a time):
for (char c : str) {
if (c == '"' || c == '\\' || c < 0x20)
return true;
}
SIMD (16 bytes at once):
__m128i chunk = load_16_bytes(str);
__m128i needs_escape = check_all_conditions_parallel(chunk);
if (!needs_escape)
return false; // Fast path!
Dataset | Baseline | No SIMD | Impact | Speedup |
---|---|---|---|---|
3,231 MB/s | 2,245 MB/s | -31% | 1.44x | |
CITM | 2,341 MB/s | 2,273 MB/s | -3% | 1.03x |
Why Different Impact?
Traditional:
std::to_string(value).length(); // Allocates string just to count!
Optimized:
fast_digit_count(value); // Bit operations + lookup table
Dataset | Baseline | No Fast Digits | Speedup |
---|---|---|---|
3,231 MB/s | 3,041 MB/s | 1.06x | |
CITM | 2,341 MB/s | 1,841 MB/s | 1.27x |
CITM has ~10,000+ integers!
Branch Prediction:
if (UNLIKELY(buffer_full)) { // CPU knows this is rare
grow_buffer();
}
// CPU optimizes for this path
Buffer Growth:
Both Optimizations | Impact | Speedup |
---|---|---|
Twitter & CITM | ~2% | 1.02x |
Small but free!
All Optimizations Together:
Optimization | Twitter Contribution | CITM Contribution |
---|---|---|
Consteval | +99% (1.99x) | +165% (2.65x) |
SIMD Escaping | +44% (1.44x) | +3% (1.03x) |
Fast Digits | +6% (1.06x) | +27% (1.27x) |
Branch Hints | +1.5% | +1.5% |
Buffer Growth | +0.8% | +0.8% |
TOTAL | ~2.95x faster | ~3.5x faster |
From Baseline to Optimized:
API Server Example:
Serialization Time:
nlohmann::json: 210 seconds (3.5 minutes)
RapidJSON: 102 seconds (1.7 minutes)
Serde (Rust): 38 seconds
yyjson: 24 seconds
simdjson: 14.5 seconds
Time saved: 195 seconds vs nlohmann (93% reduction)
Compile-Time optimizations can be awesome
SIMD Everywhere
Avoid Hidden Costs
std::to_string()
log10(value)
Every Optimization Matters
Welcome to the future of C++ serialization!
Daniel Lemire and Francisco Geiman Thiesen
GitHub: github.com/simdjson/simdjson
Thank you!
The code was really painful to read, this is probably sufficient.