▲ 2 r/HiLoad+1 crossposts

Stop Adding Layers: Your Architecture Might Be the Bottleneck

I built a pretty ambitious project, shared part of it with the community through "silentJson", and wrote a detailed article explaining how to achieve some pretty crazy performance results.

The results were so unrealistic to some people that I was accused of spam, and the article was removed from a few subreddits.

Still, I'm genuinely grateful that many people at least took the time to read the articles, and that some of them actually appreciated the work.

So, here is the continuation.

The problem isn't only that we need to remove unnecessary layers, conversions, or simplify the logic. The bigger problem is that all of this is actually standard practice in production systems. And most people simply don't notice it anymore.

Let me give you a hint where to look.

Take PL/pgSQL, for example. You can put the logic directly inside the database.

Or Elasticsearch. It works in a similar way.

You probably use approaches like these all the time.

And while PostgreSQL itself doesn't necessarily give you some massive raw speed advantage, it can still give you a huge overall performance win because you are not constantly moving information through layers of OOP abstractions and paying the cost of all those calls and transformations.

The business logic is already where it needs to be.

You can use the same idea in your own applications.

But for some reason, we often care more about creating interfaces and generics so that we can mock everything in unit tests instead of simply writing integration tests.

This can significantly reduce the load on the system and, more importantly, make your tests validate real scenarios instead of proving that "sum(1, 2)" returns 3.

So where do you start?

Take a piece of paper.

Write down the actual goal of the system, what you have, and what you want to achieve.

Use blocks, lists, arrows, whatever works for you.

At this stage, completely forget about the programming language.

Seriously.

If you start thinking about Go, Java, Rust, interfaces, frameworks, or whatever else too early, you will probably start designing around the language instead of designing around the problem.

First, work out how the data should actually flow through the system.

Only at that point should you start thinking about what data you need, how it should be stored, and, perhaps even more importantly, what should not be stored or processed at all.

Then start writing code.

But write it according to the data flow and decomposition you already designed.

At every stage, use only what is actually necessary.

For example: maps, interface{}, channels, mutexes, goroutines.

Sometimes it is incredibly convenient to throw together a worker pool using channels. It's simple, readable, and often works very well.

But do you actually need it?

Creating goroutines isn't free.

A WaitGroup isn't automatically the best solution just because it's convenient.

And combining maps with mutexes can very quickly turn into an adventure where you're trying to figure out who locked what, when it happened, and why something never got unlocked.

Sometimes a simple slice, struct, or primitive type is all you need.

Programming has spent decades developing good ways to work with data efficiently. Look at the natural primitives we already have.

Take JSON.

It is simple, universal, and fast enough for an enormous number of real-world applications.

Yet many people consider JSON "slow".

Usually, the problem isn't JSON itself.

The problem is what you put inside it and what you make it do.

Add a "time.Time", a "map[string]interface{}", or several layers of dynamic structures, and suddenly you get the performance penalty you were blaming on JSON.

The same principle applies everywhere else.

If the job is to store data, store it.

If the job is to return data, return it.

Don't build obstacles in between.

The fewer steps the information has to go through, the faster it reaches its destination.

And don't try to return more than you actually have.

Separate responsibilities.

If a service is supposed to provide data, it should provide data.

If a BFF is supposed to compose data, let it compose data.

And let it do that as quickly and simply as possible.

It shouldn't be transforming everything just because it can.

A BFF doesn't need to contain your business logic.

Wait.

What about business logic?

This is where things get interesting.

Business logic can actually operate on different levels.

Some logic changes the data itself. Other logic changes how that data is presented.

The first kind belongs as close as possible to the point where the data is created, retrieved, or indexed.

The second kind can live at the composition layer, frontend, or a dedicated business layer.

Where exactly you put it depends on the architecture and business requirements of the project.

And that's why the architecture should be designed before you start writing the code.

Once the data flow is clear, you can look at the system and ask a much more useful question:

"Where is the bottleneck?"

Then remove everything on the path that creates an unnecessary restriction.

Profile your application.

If you don't know how, ask an LLM. There is no shame in that.

When you've done all of this and finally discover that the bottleneck isn't your architecture anymore, but the hardware itself, then you can start asking whether that abstraction, interface, mock, or extra layer is really worth its cost.

Especially when the only reason for adding it was to prove that one tiny function correctly calculates 2 + 2.

I'm curious how other people approach this.

How do you design, develop, and profile your systems?

And how do you decide when an abstraction is actually useful and when it is just another layer between the data and its destination?

reddit.com
u/No-Job-5616 — 19 hours ago

From Frameworks to Metal: How to Reach 102,000 RPS with Go and Stop Being Afraid of High Load

From Frameworks to Metal: How to Reach 102,000 RPS with Go and Stop Being Afraid of High Load

There comes a point in a developer's career when you start getting tired of constraints.

Standard frameworks. Ready-made ORMs. Abstraction on top of abstraction. Layers that exist to hide other layers. And somewhere at the bottom of all of it, a simple map that just wanted to find a key.

At first, it's convenient. Then it gets cramped.

Eventually, you start wanting to do the opposite: remove everything unnecessary and see what the machine can actually do when you stop getting in its way.

That's how my current project evolved — a high-load product aggregator written in Go.

No SQL in the hot path. No heavyweight frameworks. No standard JSON marshaler. Just mmap, custom indexes, direct memory access, and a little bit of unsafe.

And the result turned out to be somewhat more interesting than I expected.


From Syntax to Hardware

Learning Go usually starts innocently enough.

First:

go func() {
    ...
}()

Then come:

  • goroutines;
  • channels;
  • sync.Mutex;
  • WaitGroup;
  • sync.Map;
  • sync.Pool;
  • atomic;
  • worker pools;
  • queues;
  • caching;
  • Docker;
  • microservices.

And then real-world load arrives.

Suddenly, the question is no longer:

> Do you know how to write concurrent code?

The question becomes:

> How much work actually needs to be done for a single request?

That's a completely different level of optimization.

Because if a request goes through ten abstractions, creates several objects, performs multiple data conversions, hits a database, retrieves the data, converts it into structs, and then converts those structs back into JSON — no amount of atomic is going to save you.

And that's when the evolution starts going in reverse.

Slices instead of unnecessary maps.

Direct memory access instead of intermediate objects.

Atomic operations where an atomic is actually enough.

mmap instead of constant I/O.

Indexes instead of calculations.

Raw bytes instead of conversions.

And eventually, one question:

> What if we simply remove everything from the hot path that isn't absolutely necessary?


Makodb: A Database That Doesn't Try to Be SQL

The project is built around a custom mmap-based KV database — Makodb.

Its job is deliberately narrow: serve data as quickly as possible for read-heavy workloads.

There is no SQL query, ORM, or conversion pipeline in the hot path:

storage
   ↓
object
   ↓
ORM
   ↓
struct
   ↓
JSON

Instead:

mmap
  ↓
index
  ↓
docID
  ↓
raw data
  ↓
JSON buffer

The main principles are:

  • lock-free reads;
  • one write lock per shard;
  • mmap;
  • sharding;
  • sorted uint64 indexes;
  • merge-style intersection/union;
  • direct range queries;
  • sort indexes;
  • minimal allocations;
  • no objects in the hot read path.

One particularly interesting component is the turbo index.

An index is simply a sorted array of uint64 values:

[docID1][docID2][docID3]...[docIDN]

So intersecting two indexes becomes a standard merge operation:

A:  1  4  7  12  20  35
B:  2  4  8  12  19  35

AND:

    4        12       35

No temporary maps.

No temporary objects.

No O(n*m).

Just two pointers and sequential memory access.


700,000+ Landing Pages and 4 Million+ Products

This isn't a synthetic benchmark with a single Hello World endpoint.

The actual project contains:

  • 700k+ landing pages;
  • 4M+ products;
  • categories;
  • slugs;
  • sorting;
  • filters;
  • price ranges;
  • text indexes;
  • pagination;
  • different combinations of parameters.

And one principle is particularly important:

> There is no special magic for page 1 and page 500,000.

There is no situation where the first page is instant and deep pagination suddenly turns into OFFSET 500000.

There is an index.

The required position can be accessed directly.

Sorting is also not performed from scratch for every request — prebuilt sort indexes handle it.

Filtering becomes a set of operations on indexes.


And Then It Turned Out the Database Wasn't the Problem

This was probably the most interesting stage.

Under normal load, data queries were completing in roughly:

0.161–3 ms.

But the browser was showing considerably more.

So I started measuring.

Data retrieval — fast.

Filtering — fast.

Indexes — fast.

Result composition — fast.

And then:

w.Write(...)

Suddenly, it became clear that sending the result could sometimes cost more than computing it.

That's where things got interesting.


Goodbye, encoding/json

The standard:

json.NewEncoder(w).Encode(v)

is perfectly fine for most applications.

But if the goal is to squeeze every possible bit of performance out of a single machine, the standard JSON marshaler becomes expensive.

So I built SilentJSON.

The idea is simple:

> We already know the response structure. Why should the runtime figure it out again?

Instead of dynamically inspecting types through reflection, the serializer uses precomputed field metadata, offsets, and specialized marshal functions.

Conceptually:

struct
  ↓
known offset
  ↓
direct memory access
  ↓
append bytes
  ↓
JSON

Instead of:

interface{}
   ↓
reflect
   ↓
temporary object
   ↓
conversion
   ↓
JSON

And this leads to another important principle.


If the Structure Is Known, Don't Make It Universal

Universality is usually considered a virtue.

But universality has a cost.

For example:

map[string]interface{}

is convenient.

But you pay for that convenience in:

  • predictability;
  • allocations;
  • cache locality;
  • type safety;
  • serialization speed.

For a narrow, specialized project, you can make a completely different tradeoff.

For example:

[]{
    key,
    weight,
    value
}

Or a specialized raw type.

Or a response structure whose layout is known in advance.

It's less universal.

But extremely transparent.

And fast.


The Most Revealing Experiment: GET vs HEAD

At some point, there was a very simple way to test the hypothesis.

If HEAD executes the entire internal request logic but doesn't send the response body, we can compare it directly with a normal GET.

The results were very revealing.

At:

Concurrency: 5800
Duration:    60s

HEAD

Total: 6,167,463 requests
RPS:   102,339
Errors: 0.0%

Average latency:

/shop                 44 ms
/shop/{category}      45 ms
/shop/{slug}          92 ms
/products              44 ms
/products/turbo        45 ms
/categories/tree      46 ms

Maximum latency:

310–483 ms

In other words, the entire internal workload can be processed at more than 100,000 requests per second.

Now the same server, the same workload, the same concurrency — but a real GET.

GET

Total: 3,355,057 requests
RPS:   55,632
Errors: 0.0%

Average latency:

/shop                 84 ms
/shop/{category}      86 ms
/shop/{slug}         164 ms
/products              81 ms
/products/turbo        77 ms
/categories/tree      81 ms

There it is.

Almost a factor of two.

Not search. Not indexes. Not filtering. Not the database.

The result has to be assembled and physically pushed out of the machine.


Where the Algorithm Ends and Physics Begins

The architecture now looks roughly like this:

                    102k RPS
                       │
                       ▼
             ┌─────────────────┐
             │    Go / CPU     │
             │                 │
             │     Makodb      │
             │     indexes     │
             │     filters     │
             │     business    │
             └────────┬────────┘
                      │
                  JSON ready
                      │
                      ▼
             ┌─────────────────┐
             │ output pipeline │
             │                 │
             │ memory → kernel │
             │ → socket → NIC  │
             └────────┬────────┘
                      │
                      ▼
                   ~55k RPS

And this is where it gets slightly funny.

Because the application is already saying:

> "I can do more."

And the hardware responds:

> "Where exactly are you planning to put all those bytes?"


5,800 Concurrent Requests

At 5800 concurrent requests, the system reached approximately:

55–56k RPS of full GET requests.

And the interesting part is that it doesn't collapse.

0 errors
0.0%

No avalanche of timeouts.

No gradual degradation into death.

No sudden throughput collapse.

It simply reaches its physical ceiling.


What Happens at 105,800 Concurrent?

Naturally, the next question was:

> What if we push it harder?

So:

go run main.go -c 105800 -d 60s

Result:

Total: 3,419,927 requests
RPS:   54,801
Errors: 0.0%

Average latency increased:

/shop             ~1.51 s
/category         ~1.52 s
/slug             ~3.03 s
/products         ~1.51 s
/products/turbo   ~1.51 s
/categories       ~1.51 s

But the server didn't die.

That's an important distinction.

The system couldn't process more than roughly 55k RPS — but it held that ceiling under an absurd number of concurrent requests.

So as concurrency increases:

200
  ↓
800
  ↓
2800
  ↓
5800
  ↓
15800
  ↓
55800
  ↓
105800

throughput eventually stops growing.

But it doesn't collapse.

We hit a plateau.


Load Test Series

Concurrency RPS Avg latency Errors
200 36,209 2–9 ms 0%
800 53,202 7–23 ms 0%
2,800 55,781 34–78 ms 0%
5,800 55,632 77–164 ms 0%
15,800 55,868 219–448 ms 0%
55,800 55,061 794–1,599 ms 0%
105,800 54,801 1,507–3,025 ms 0%

And that's probably the most interesting graph in the whole experiment.

RPS
60k ┤
    │        ┌─────────────────────────────────────
55k ┤────────┘
    │
50k ┤
    │
40k ┤
    │
30k ┤
    │
20k ┤
    │
10k ┤
    └──────────────────────────────────────────────
      200  800  2.8k  5.8k  15.8k  55.8k  105.8k

After roughly 2–3 thousand concurrent requests:

throughput becomes almost horizontal.

What keeps increasing is latency.

That's classic behavior for a system that has reached a resource ceiling.


The Funny Part: This Isn't a Server Cluster

This experiment wasn't performed on some enormous fleet of machines.

There wasn't:

load balancer
    ↓
20 application servers
    ↓
10 database servers
    ↓
Redis cluster
    ↓
Kafka cluster

It's one machine.

Ordinary workstation/home-server hardware.

And that's exactly why the experiment became interesting.

At one point I used to test what Go could do with a simple Hello World.

And I remember a very different picture: after a few hundred requests, things started going south.

Here:

5,800 concurrent
→ ~55k GET RPS

5,800 concurrent
→ ~102k HEAD RPS

105,800 concurrent
→ ~55k GET RPS
→ 0 errors

At some point, you stop asking:

> "How do I optimize Go?"

And start asking:

> "Where exactly does the program end and the computer begin?"


Why Does This Work?

1. Minimal Work Per Request

The main secret isn't some magical function.

It's much more boring:

> A request should do as little work as possible.

If the data is already indexed, don't search for it.

If the sort order is already known, don't sort it.

If two conditions can be intersected using a merge operation, don't build another map.

If the JSON structure is known, don't discover it through reflection.

If the document already lives in mmap, don't drag it through several intermediate representations.


2. Indexes Instead of Computation

A product may have indexes for:

category
brand
price
rating
date
text
...

A query such as:

category = electronics
AND
brand = x
AND
price = 5000..50000
ORDER BY price DESC

doesn't become:

find all products
→ check category
→ check brand
→ check price
→ sort everything
→ take 60

Instead:

category index
       ↓
      AND
       ↓
brand index
       ↓
      AND
       ↓
price range
       ↓
sort index
       ↓
page

Most of the work is performed on compact numeric indexes.


3. mmap

Data lives in memory-mapped storage.

The read path doesn't have to constantly perform traditional filesystem I/O.

Conceptually:

disk file
   │
   ▼
 mmap
   │
   ▼
memory address
   │
   ▼
read

This is particularly well suited to read-heavy workloads.


4. Lock-Free Read Path

Writes are a different story.

But reads shouldn't have to wait for other reads.

There is no global:

mu.Lock()
...
mu.Unlock()

on every request.

The hot read path uses atomic reads and direct memory access.

Sharding additionally distributes write contention.


5. JSON Became the Last Enemy

And this is probably the most important conclusion of the entire experiment.

After optimizing the database, indexes, and search, it turned out that the next major problem wasn't retrieving the data.

It was sending it.

That's why moving to a specialized JSON marshaler made such a noticeable difference.

And then something even more interesting became apparent:

> Even when JSON is already as lean as possible, the bytes still have to physically leave the machine.

At that point, no architectural abstraction can help.

The bytes have to move.


Where Is the Real Bottleneck Now?

Very roughly:

request
   │
   ├── routing
   ├── filtering
   ├── index intersection
   ├── sorting
   ├── document lookup
   ├── JSON composition
   │
   └── network output

The first stages have become fast enough that optimizing them further no longer produces the biggest gains.

Now the problem is much more physical:

throughput ≈ available bandwidth / response size

Of course, the real system is more complicated.

But at this scale, that's already the right way to think about it.

If a response is N bytes and the server needs to send tens of thousands of those responses every second, we very quickly stop talking about algorithms.

We start talking about:

how many bytes per second the entire system can move.


And That's Where It Gets Really Funny

You optimize the algorithm.

Then memory.

Then indexes.

Then JSON.

Then unnecessary conversions.

And eventually you discover:

CPU:
    "I can still go."

Makodb:
    "I can still go."

Indexes:
    "We're barely working."

JSON:
    "I've already been put on a diet."

Network stack:
    "Guys..."

PCIe:
    "Slow down."

NIC:
    "Where exactly are you taking all those bytes?"

And this is actually a beautiful stage of optimization.

Because it means you have finally reached the hardware.


What I Took Away from the Experiment

The most important result wasn't 102,339.

It wasn't even 55,632 RPS.

The real conclusion is much simpler:

> High load itself isn't scary. The scary part is how much work you force the system to perform for each request.

If a request requires:

SQL
→ ORM
→ struct
→ reflection
→ map
→ conversion
→ JSON
→ copy
→ socket

then high load quickly turns into a fight against your own architecture.

If the request looks more like:

request
  ↓
index
  ↓
memory
  ↓
bytes
  ↓
socket

then suddenly a single ordinary machine can perform tens of thousands of these operations every second.

And if you remove the response body and leave only the internal processing, you get more than 100,000 requests per second.


Final Numbers

On the current hardware, the experiment demonstrated:

Full GET

≈55,000 RPS

with:

  • 5,800 concurrent requests;
  • real JSON;
  • real business logic;
  • filtering;
  • sorting;
  • index operations;
  • 0.0% errors.

HEAD

102,339 RPS

with the same internal request processing and 0.0% errors.

Extreme concurrency

105,800 concurrent requests

without the server crashing and without errors.

Load

Millions of requests per minute.

And after a certain level of concurrency, the system doesn't start dying.

It simply says:

> "I can't go any faster. But I'm not going to fall over either."

And perhaps that's the best result a load test can give you.

Not the biggest number.

But a predictable plateau.

Because a server that turns into a pumpkin when you go from 5,000 to 100,000 concurrent requests is a problem.

A server that says:

> "My ceiling is here. Beyond that, things will simply get slower."

is already a system you can reason about, plan around, and scale.

And that leaves the most interesting question:

If the algorithms are no longer the bottleneck, JSON is already on a diet, and the CPU still has room — how far can we go if the next optimization target is no longer the code, but the path the bytes take from memory all the way to the network interface?

[user nodownload]$ go run main.go -c 5800 -d 60s
[1m0s] Running... shop:2180588 shopCat:1246559 shopSlug:1559589 products:500109 turbo:312322 cats:436086
=== HEAD Load Test Results ===
Duration:   1m0.214s
Concurrency: 5800

GET /shop                 2182135 reqs  36240 req/s  avg=44ms     min=12ms     max=353ms    err=0.0%
GET /shop/{category}      1247457 reqs  20717 req/s  avg=44ms     min=12ms     max=371ms    err=0.0%
GET /shop/{slug}          1561944 reqs  25940 req/s  avg=91ms     min=40ms     max=494ms    err=0.0%
GET /products             500492 reqs  8312 req/s  avg=44ms     min=19ms     max=367ms    err=0.0%
GET /products/turbo       312575 reqs  5191 req/s  avg=44ms     min=16ms     max=313ms    err=0.0%
GET /categories/tree      436449 reqs  7248 req/s  avg=46ms     min=12ms     max=344ms    err=0.0%

Total: 6241052 requests, 0 errors (0.00%), 103648 req/s
[user nodownload]$ cd ../load/
[user load]$ go run main.go -c 5800 -d 60s
[1m0s] Running... shop:1182442 shopCat:675229 shopSlug:843211 products:270361 turbo:169183 cats:236240
=== Load Test Results ===
Duration:   1m0.278s
Concurrency: 5800

GET /shop                 1184095 reqs  19644 req/s  avg=84ms     min=14ms     max=650ms    err=0.0%
GET /shop/{category}      676202 reqs  11218 req/s  avg=86ms     min=17ms     max=649ms    err=0.0%
GET /shop/{slug}          845485 reqs  14026 req/s  avg=163ms    min=83ms     max=877ms    err=0.0%
GET /products             270718 reqs  4491 req/s  avg=80ms     min=18ms     max=547ms    err=0.0%
GET /products/turbo       169429 reqs  2811 req/s  avg=76ms     min=18ms     max=496ms    err=0.0%
GET /categories/tree      236530 reqs  3924 req/s  avg=80ms     min=16ms     max=552ms    err=0.0%

Total: 3382459 requests, 0 errors (0.00%), 56115 req/s
reddit.com
u/No-Job-5616 — 2 days ago
▲ 1 r/HiLoad

From Frameworks to Metal: How to Reach 102,000 RPS with Go and Stop Being Afraid of High Load

There comes a point in a developer's career when you start getting tired of constraints.

https://preview.redd.it/z3l78pja9dkh1.jpg?width=1376&format=pjpg&auto=webp&s=04224163ce49e93dad0f5081aa1ae4807a9d6dd8

Standard frameworks. Ready-made ORMs. Abstraction on top of abstraction. Layers that exist to hide other layers. And somewhere at the bottom of all of it, a simple map that just wanted to find a key.

At first, it's convenient. Then it gets cramped.

Eventually, you start wanting to do the opposite: remove everything unnecessary and see what the machine can actually do when you stop getting in its way.

That's how my current project evolved — a high-load product aggregator written in Go.

No SQL in the hot path. No heavyweight frameworks. No standard JSON marshaler. Just mmap, custom indexes, direct memory access, and a little bit of unsafe.

And the result turned out to be somewhat more interesting than I expected.

From Syntax to Hardware

Learning Go usually starts innocently enough.

First:

go func() {
    ...
}()

Then come:

  • goroutines;
  • channels;
  • sync.Mutex;
  • WaitGroup;
  • sync.Map;
  • sync.Pool;
  • atomic;
  • worker pools;
  • queues;
  • caching;
  • Docker;
  • microservices.

And then real-world load arrives.

Suddenly, the question is no longer:

>Do you know how to write concurrent code?

The question becomes:

>How much work actually needs to be done for a single request?

That's a completely different level of optimization.

Because if a request goes through ten abstractions, creates several objects, performs multiple data conversions, hits a database, retrieves the data, converts it into structs, and then converts those structs back into JSON — no amount of atomic is going to save you.

And that's when the evolution starts going in reverse.

Slices instead of unnecessary maps.

Direct memory access instead of intermediate objects.

Atomic operations where an atomic is actually enough.

mmap instead of constant I/O.

Indexes instead of calculations.

Raw bytes instead of conversions.

And eventually, one question:

>What if we simply remove everything from the hot path that isn't absolutely necessary?

Makodb: A Database That Doesn't Try to Be SQL

The project is built around a custom mmap-based KV database — Makodb.

Its job is deliberately narrow: serve data as quickly as possible for read-heavy workloads.

There is no SQL query, ORM, or conversion pipeline in the hot path:

storage
   ↓
object
   ↓
ORM
   ↓
struct
   ↓
JSON

Instead:

mmap
  ↓
index
  ↓
docID
  ↓
raw data
  ↓
JSON buffer

The main principles are:

  • lock-free reads;
  • one write lock per shard;
  • mmap;
  • sharding;
  • sorted uint64 indexes;
  • merge-style intersection/union;
  • direct range queries;
  • sort indexes;
  • minimal allocations;
  • no objects in the hot read path.

One particularly interesting component is the turbo index.

An index is simply a sorted array of uint64 values:

[docID1][docID2][docID3]...[docIDN]

So intersecting two indexes becomes a standard merge operation:

A:  1  4  7  12  20  35
B:  2  4  8  12  19  35

AND:

    4        12       35

No temporary maps.

No temporary objects.

No O(n*m).

Just two pointers and sequential memory access.

700,000+ Landing Pages and 4 Million+ Products

This isn't a synthetic benchmark with a single Hello World endpoint.

The actual project contains:

  • 700k+ landing pages;
  • 4M+ products;
  • categories;
  • slugs;
  • sorting;
  • filters;
  • price ranges;
  • text indexes;
  • pagination;
  • different combinations of parameters.

And one principle is particularly important:

>There is no special magic for page 1 and page 500,000.

There is no situation where the first page is instant and deep pagination suddenly turns into OFFSET 500000.

There is an index.

The required position can be accessed directly.

Sorting is also not performed from scratch for every request — prebuilt sort indexes handle it.

Filtering becomes a set of operations on indexes.

And Then It Turned Out the Database Wasn't the Problem

This was probably the most interesting stage.

Under normal load, data queries were completing in roughly:

0.161–3 ms.

But the browser was showing considerably more.

So I started measuring.

Data retrieval — fast.

Filtering — fast.

Indexes — fast.

Result composition — fast.

And then:

w.Write(...)

Suddenly, it became clear that sending the result could sometimes cost more than computing it.

That's where things got interesting.

Goodbye, encoding/json

The standard:

json.NewEncoder(w).Encode(v)

is perfectly fine for most applications.

But if the goal is to squeeze every possible bit of performance out of a single machine, the standard JSON marshaler becomes expensive.

So I built SilentJSON.

The idea is simple:

>We already know the response structure. Why should the runtime figure it out again?

Instead of dynamically inspecting types through reflection, the serializer uses precomputed field metadata, offsets, and specialized marshal functions.

Conceptually:

struct
  ↓
known offset
  ↓
direct memory access
  ↓
append bytes
  ↓
JSON

Instead of:

interface{}
   ↓
reflect
   ↓
temporary object
   ↓
conversion
   ↓
JSON

And this leads to another important principle.

If the Structure Is Known, Don't Make It Universal

Universality is usually considered a virtue.

But universality has a cost.

For example:

map[string]interface{}

is convenient.

But you pay for that convenience in:

  • predictability;
  • allocations;
  • cache locality;
  • type safety;
  • serialization speed.

For a narrow, specialized project, you can make a completely different tradeoff.

For example:

[]{
    key,
    weight,
    value
}

Or a specialized raw type.

Or a response structure whose layout is known in advance.

It's less universal.

But extremely transparent.

And fast.

The Most Revealing Experiment: GET vs HEAD

At some point, there was a very simple way to test the hypothesis.

If HEAD executes the entire internal request logic but doesn't send the response body, we can compare it directly with a normal GET.

The results were very revealing.

At:

Concurrency: 5800
Duration:    60s

HEAD

Total: 6,167,463 requests
RPS:   102,339
Errors: 0.0%

Average latency:

/shop                 44 ms
/shop/{category}      45 ms
/shop/{slug}          92 ms
/products              44 ms
/products/turbo        45 ms
/categories/tree      46 ms

Maximum latency:

310–483 ms

In other words, the entire internal workload can be processed at more than 100,000 requests per second.

Now the same server, the same workload, the same concurrency — but a real GET.

GET

Total: 3,355,057 requests
RPS:   55,632
Errors: 0.0%

Average latency:

/shop                 84 ms
/shop/{category}      86 ms
/shop/{slug}         164 ms
/products              81 ms
/products/turbo        77 ms
/categories/tree      81 ms

There it is.

Almost a factor of two.

Not search. Not indexes. Not filtering. Not the database.

The result has to be assembled and physically pushed out of the machine.

Where the Algorithm Ends and Physics Begins

The architecture now looks roughly like this:

                    102k RPS
                       │
                       ▼
             ┌─────────────────┐
             │    Go / CPU     │
             │                 │
             │     Makodb      │
             │     indexes     │
             │     filters     │
             │     business    │
             └────────┬────────┘
                      │
                  JSON ready
                      │
                      ▼
             ┌─────────────────┐
             │ output pipeline │
             │                 │
             │ memory → kernel │
             │ → socket → NIC  │
             └────────┬────────┘
                      │
                      ▼
                   ~55k RPS

And this is where it gets slightly funny.

Because the application is already saying:

>"I can do more."

And the hardware responds:

>"Where exactly are you planning to put all those bytes?"

5,800 Concurrent Requests

At 5800 concurrent requests, the system reached approximately:

55–56k RPS of full GET requests.

And the interesting part is that it doesn't collapse.

0 errors
0.0%

No avalanche of timeouts.

No gradual degradation into death.

No sudden throughput collapse.

It simply reaches its physical ceiling.

What Happens at 105,800 Concurrent?

Naturally, the next question was:

>What if we push it harder?

So:

go run main.go -c 105800 -d 60s

Result:

Total: 3,419,927 requests
RPS:   54,801
Errors: 0.0%

Average latency increased:

/shop             ~1.51 s
/category         ~1.52 s
/slug             ~3.03 s
/products         ~1.51 s
/products/turbo   ~1.51 s
/categories       ~1.51 s

But the server didn't die.

That's an important distinction.

The system couldn't process more than roughly 55k RPS — but it held that ceiling under an absurd number of concurrent requests.

So as concurrency increases:

200
  ↓
800
  ↓
2800
  ↓
5800
  ↓
15800
  ↓
55800
  ↓
105800

throughput eventually stops growing.

But it doesn't collapse.

We hit a plateau.

Load Test Series

Concurrency RPS Avg latency Errors
200 36,209 2–9 ms 0%
800 53,202 7–23 ms 0%
2,800 55,781 34–78 ms 0%
5,800 55,632 77–164 ms 0%
15,800 55,868 219–448 ms 0%
55,800 55,061 794–1,599 ms 0%
105,800 54,801 1,507–3,025 ms 0%

And that's probably the most interesting graph in the whole experiment.

RPS
60k ┤
    │        ┌─────────────────────────────────────
55k ┤────────┘
    │
50k ┤
    │
40k ┤
    │
30k ┤
    │
20k ┤
    │
10k ┤
    └──────────────────────────────────────────────
      200  800  2.8k  5.8k  15.8k  55.8k  105.8k

After roughly 2–3 thousand concurrent requests:

throughput becomes almost horizontal.

What keeps increasing is latency.

That's classic behavior for a system that has reached a resource ceiling.

The Funny Part: This Isn't a Server Cluster

This experiment wasn't performed on some enormous fleet of machines.

There wasn't:

load balancer
    ↓
20 application servers
    ↓
10 database servers
    ↓
Redis cluster
    ↓
Kafka cluster

It's one machine.

Ordinary workstation/home-server hardware.

And that's exactly why the experiment became interesting.

At one point I used to test what Go could do with a simple Hello World.

And I remember a very different picture: after a few hundred requests, things started going south.

Here:

5,800 concurrent
→ ~55k GET RPS

5,800 concurrent
→ ~102k HEAD RPS

105,800 concurrent
→ ~55k GET RPS
→ 0 errors

At some point, you stop asking:

>"How do I optimize Go?"

And start asking:

>"Where exactly does the program end and the computer begin?"

Why Does This Work?

1. Minimal Work Per Request

The main secret isn't some magical function.

It's much more boring:

>A request should do as little work as possible.

If the data is already indexed, don't search for it.

If the sort order is already known, don't sort it.

If two conditions can be intersected using a merge operation, don't build another map.

If the JSON structure is known, don't discover it through reflection.

If the document already lives in mmap, don't drag it through several intermediate representations.

2. Indexes Instead of Computation

A product may have indexes for:

category
brand
price
rating
date
text
...

A query such as:

category = electronics
AND
brand = x
AND
price = 5000..50000
ORDER BY price DESC

doesn't become:

find all products
→ check category
→ check brand
→ check price
→ sort everything
→ take 60

Instead:

category index
       ↓
      AND
       ↓
brand index
       ↓
      AND
       ↓
price range
       ↓
sort index
       ↓
page

Most of the work is performed on compact numeric indexes.

3. mmap

Data lives in memory-mapped storage.

The read path doesn't have to constantly perform traditional filesystem I/O.

Conceptually:

disk file
   │
   ▼
 mmap
   │
   ▼
memory address
   │
   ▼
read

This is particularly well suited to read-heavy workloads.

4. Lock-Free Read Path

Writes are a different story.

But reads shouldn't have to wait for other reads.

There is no global:

mu.Lock()
...
mu.Unlock()

on every request.

The hot read path uses atomic reads and direct memory access.

Sharding additionally distributes write contention.

5. JSON Became the Last Enemy

And this is probably the most important conclusion of the entire experiment.

After optimizing the database, indexes, and search, it turned out that the next major problem wasn't retrieving the data.

It was sending it.

That's why moving to a specialized JSON marshaler made such a noticeable difference.

And then something even more interesting became apparent:

>Even when JSON is already as lean as possible, the bytes still have to physically leave the machine.

At that point, no architectural abstraction can help.

The bytes have to move.

Where Is the Real Bottleneck Now?

Very roughly:

request
   │
   ├── routing
   ├── filtering
   ├── index intersection
   ├── sorting
   ├── document lookup
   ├── JSON composition
   │
   └── network output

The first stages have become fast enough that optimizing them further no longer produces the biggest gains.

Now the problem is much more physical:

throughput ≈ available bandwidth / response size

Of course, the real system is more complicated.

But at this scale, that's already the right way to think about it.

If a response is N bytes and the server needs to send tens of thousands of those responses every second, we very quickly stop talking about algorithms.

We start talking about:

how many bytes per second the entire system can move.

And That's Where It Gets Really Funny

You optimize the algorithm.

Then memory.

Then indexes.

Then JSON.

Then unnecessary conversions.

And eventually you discover:

CPU:
    "I can still go."

Makodb:
    "I can still go."

Indexes:
    "We're barely working."

JSON:
    "I've already been put on a diet."

Network stack:
    "Guys..."

PCIe:
    "Slow down."

NIC:
    "Where exactly are you taking all those bytes?"

And this is actually a beautiful stage of optimization.

Because it means you have finally reached the hardware.

What I Took Away from the Experiment

The most important result wasn't 102,339.

It wasn't even 55,632 RPS.

The real conclusion is much simpler:

>High load itself isn't scary. The scary part is how much work you force the system to perform for each request.

If a request requires:

SQL
→ ORM
→ struct
→ reflection
→ map
→ conversion
→ JSON
→ copy
→ socket

then high load quickly turns into a fight against your own architecture.

If the request looks more like:

request
  ↓
index
  ↓
memory
  ↓
bytes
  ↓
socket

then suddenly a single ordinary machine can perform tens of thousands of these operations every second.

And if you remove the response body and leave only the internal processing, you get more than 100,000 requests per second.

Final Numbers

On the current hardware, the experiment demonstrated:

Full GET

≈55,000 RPS

with:

  • 5,800 concurrent requests;
  • real JSON;
  • real business logic;
  • filtering;
  • sorting;
  • index operations;
  • 0.0% errors.

HEAD

102,339 RPS

with the same internal request processing and 0.0% errors.

Extreme concurrency

105,800 concurrent requests

without the server crashing and without errors.

Load

Millions of requests per minute.

And after a certain level of concurrency, the system doesn't start dying.

It simply says:

>"I can't go any faster. But I'm not going to fall over either."

And perhaps that's the best result a load test can give you.

Not the biggest number.

But a predictable plateau.

Because a server that turns into a pumpkin when you go from 5,000 to 100,000 concurrent requests is a problem.

A server that says:

>"My ceiling is here. Beyond that, things will simply get slower."

is already a system you can reason about, plan around, and scale.

And that leaves the most interesting question:

If the algorithms are no longer the bottleneck, JSON is already on a diet, and the CPU still has room — how far can we go if the next optimization target is no longer the code, but the path the bytes take from memory all the way to the network interface?

[user nodownload]$ go run main.go -c 5800 -d 60s
[1m0s] Running... shop:2180588 shopCat:1246559 shopSlug:1559589 products:500109 turbo:312322 cats:436086
=== HEAD Load Test Results ===
Duration:   1m0.214s
Concurrency: 5800

GET /shop                 2182135 reqs  36240 req/s  avg=44ms     min=12ms     max=353ms    err=0.0%
GET /shop/{category}      1247457 reqs  20717 req/s  avg=44ms     min=12ms     max=371ms    err=0.0%
GET /shop/{slug}          1561944 reqs  25940 req/s  avg=91ms     min=40ms     max=494ms    err=0.0%
GET /products             500492 reqs  8312 req/s  avg=44ms     min=19ms     max=367ms    err=0.0%
GET /products/turbo       312575 reqs  5191 req/s  avg=44ms     min=16ms     max=313ms    err=0.0%
GET /categories/tree      436449 reqs  7248 req/s  avg=46ms     min=12ms     max=344ms    err=0.0%

Total: 6241052 requests, 0 errors (0.00%), 103648 req/s
[user nodownload]$ cd ../load/
[user load]$ go run main.go -c 5800 -d 60s
[1m0s] Running... shop:1182442 shopCat:675229 shopSlug:843211 products:270361 turbo:169183 cats:236240
=== Load Test Results ===
Duration:   1m0.278s
Concurrency: 5800

GET /shop                 1184095 reqs  19644 req/s  avg=84ms     min=14ms     max=650ms    err=0.0%
GET /shop/{category}      676202 reqs  11218 req/s  avg=86ms     min=17ms     max=649ms    err=0.0%
GET /shop/{slug}          845485 reqs  14026 req/s  avg=163ms    min=83ms     max=877ms    err=0.0%
GET /products             270718 reqs  4491 req/s  avg=80ms     min=18ms     max=547ms    err=0.0%
GET /products/turbo       169429 reqs  2811 req/s  avg=76ms     min=18ms     max=496ms    err=0.0%
GET /categories/tree      236530 reqs  3924 req/s  avg=80ms     min=16ms     max=552ms    err=0.0%

Total: 3382459 requests, 0 errors (0.00%), 56115 req/s
reddit.com
u/No-Job-5616 — 2 days ago
▲ 2 r/HiLoad

Makodb: Continuing the MakoDB series — From SilentJson to real-world performance

Continuing the series on MakoDB. Previously, we explored the database within the context of the SilentJson package. Now, we can demonstrate how it works in a real-world scenario. To top it off, we are hitting performance levels that are truly unreachable for classic solutions. Below, you will find the demonstration and benchmark results.

When building high-read-load systems that require complex filtering, sorting, and lightning-fast pagination across millions of items, traditional relational databases or heavy search engines often introduce unnecessary overhead, garbage collection pressure, and scaling bottlenecks.

To solve this, I built Makodb — a high-performance, mmap-based, lock-free Key-Value database written in Go. It features custom Turbo Indexes, Sort Indexes, and Numeric Sort Indexes designed specifically for high-load applications like e-commerce catalogs.

In this article, I’ll walk through the architecture, design choices, and benchmark results of MakoShop, a sample multi-vendor product catalog powered by Makodb. A video demonstrating the data processing speed is attached.


The Architecture: Why Makodb?

Makodb is engineered around a few core principles to eliminate latency spikes in high-concurrency environments:

  • Lock-free Reads: Operations like Get, ForEach, and GetZeroAlloc execute without any locking, relying solely on atomic memory-mapped file (mmap) reads.

  • Single Write Lock per Shard: Writes and deletions (Put, Delete, resize, Shrink) use a RobustShmMutex (a crash-safe shared memory mutex) limited strictly to the target shard.

  • Zero Goroutines/Channels in Hot Paths: Concurrency is handled purely through thread-safe sharding and atomic pointers.

  • No OFFSET/Pagination Bottlenecks: Rather than relying on traditional SQL OFFSET/LIMIT (which degrades as you paginate deeper), Makodb uses pre-sorted uint64 arrays (docID lists) mapped directly via binary searches and merge-style intersections.


MakoShop Benchmark Results

To test the engine under heavy load, we set up a simulated multi-vendor catalog (MakoShop) featuring:

  • 780,693+ landing pages (SEO/faceted landing pages)

  • 50+ suppliers/vendors

  • ~4,000,000 raw SKUs mapped behind the landing pages

  • Total database size in memory: ~8.6 GB

We ran a concurrent load test using the following parameters:

go run main.go -c 1300 -d 60s

Load Test Output:

[1m0s] Running... shop:213702 shopCat:121410 shopSlug:152686 products:48689 turbo:30489 cats:42767
=== Load Test Results ===
Duration:   1m0.223s
Concurrency: 1300

GET /shop             214231 reqs   3557 req/s   avg=143ms   min=2ms      max=3.246s    err=0.0%
GET /shop/{category}  121709 reqs   2021 req/s   avg=140ms   min=1ms      max=2.969s    err=0.0%
GET /shop/{slug}      152979 reqs   2540 req/s   avg=119ms   min=0s       max=2.926s    err=0.0%
GET /products          48736 reqs    809 req/s   avg=70ms    min=0s       max=2.095s    err=0.0%
GET /products/turbo    30496 reqs    506 req/s   avg=30ms    min=0s       max=1.864s    err=0.0%
GET /categories/tree   42891 reqs    712 req/s   avg=181ms   min=3ms      max=2.882s    err=0.0%

Total: 611042 requests, 0 errors (0.00%), 10146 req/s

Key Takeaways from the Benchmark:

  • 10,146 requests per second (req/s) total throughput under an aggressive concurrency level of 1,300 simultaneous clients.
  • 0.00% error rate across more than 611,000 total requests in 60 seconds.
  • Sub-millisecond to low-millisecond minimum latency (min=0ms to 2ms) across core catalog endpoints.

How Turbo Indexes Work

The secret sauce behind Makodb's speed is its Turbo Index system. A turbo index is simply a sorted array of uint64 document IDs (docID) stored under a specific token key:

$$\text{Layout: } [count: uint64][docID_1: uint64][docID_2: uint64] \dots$$

Because these arrays are always kept sorted (using binary search for insertions and LSD Radix Sort), set operations like intersections (AND) and unions (OR) operate in $O(n + m)$ merge-style time rather than nested-loop $O(n \times m)$ times.

Example: Complex Product Filtering & Pagination

Here is how MakoShop executes a complex user query (e.g., Category = 1, Text Search = "phone", Price Range = 5,000–50,000, Sorted by Price Descending) entirely in-memory with zero allocations using raw turbo bytes:

// 1. Get raw candidates by category
catCandidates, _ := db.TurboBulkUnionSortedRaw([]string{"cat:1"})

// 2. Get raw candidates by text token search
textCandidates, _ := db.TurboGet("turbo_idx:text:phone")

// 3. Intersect category and text bitmaps (AND operation)
candidatesRaw := makodb.TurboBinaryIntersectRaw([][]byte{catCandidates, textCandidates})

// 4. Filter by numeric price range using NumSort range intersect
priceFiltered, _ := db.TurboGetNumSortRangeIntersectRaw("price:1", 5000, 50000, candidatesRaw)

// 5. Paginate and fetch sorted documents directly from mmap
result, _ := db.TurboSortIndexPageRawWithDocsFromDB(
    "sort:1:price_desc", // Per-category sort index
    priceFiltered,       // Filtered raw candidates
    0,                   // Page index
    60,                  // Page size (limit)
    true,                // Descending order
    "scupage:",          // Document key prefix
)

// result.DocIDs, result.Docs (JSON), and result.Total are ready instantly!

Core Technical Features

  1. Zero-Allocation Raw Operations: Methods like TurboBulkIntersectRaw and TurboBulkUnionSortedRaw process raw byte slices directly without allocating temporary []uint64 slices on the heap.

  2. Position & Numeric Sort Indexes: Separate position maps allow instantaneous pagination even when combined with complex boolean search filters.

  3. Fixed-Size Mmap with Safe Resizing: The database operates within a pre-allocated memory space, avoiding runtime memory fragmentation and expensive Unmap/Remap cycles.

Conclusion

Makodb proves that for targeted, read-heavy workloads, you don't always need a heavy external search cluster. By combining a memory-mapped KV layout with pre-sorted binary index arrays, you can effortlessly serve hundreds of thousands of landing pages and millions of SKUs at 10,000+ req/s while consuming under 10 GB of RAM. And all of this happens at speeds that are simply unreachable for traditional solutions.

u/No-Job-5616 — 6 days ago
▲ 1 r/HiLoad

Why Document-Oriented Databases Are Fast (and Where Relational Databases Start to Slow Down)

When I tell people that my embedded JSON database reads a document in 16 nanoseconds, they usually don't believe me. "JSON is just text, it's slow." "Document-oriented databases are only good for prototyping; you need PostgreSQL for serious workloads." "You can't build anything fast without B-trees."

I've heard this a hundred times. And every single time, I felt like opening perf stat to show what is actually happening under the hood.

In this article, we'll break down why document-oriented databases can be significantly faster than relational ones for certain queries, where exactly relational databases start to degrade, and how to win this battle at the hardware level.

No marketing. Just diagrams, pointers, and CPU cache lines.


Part 1. How a Relational Database Stores Your Row

Let's start with what happens when you execute the query SELECT * FROM orders WHERE id = 42 in PostgreSQL.

PostgreSQL stores data in 8 KB pages. Each page is a block on disk that contains a header, an array of line pointers (tuple pointers), and the tuples themselves, which grow from the end of the page towards the pointers:

PostgreSQL Page (8 KB)
┌───────────────────────────────────────────────────────┐
│ Page Header (24 bytes)                                │
├───────────────────────────────────────────────────────┤
│ Item Pointer 1 → offset 8140, len 68                  │
│ Item Pointer 2 → offset 8072, len 68                  │
│ Item Pointer 3 → offset 8004, len 68                  │
│ ...                                                   │
│ (pointers grow DOWNWARD →)                            │
├───────────────────────────────────────────────────────┤
│                                                       │
│                  [free space]                         │
│                                                       │
├───────────────────────────────────────────────────────┤
│ ← Tuple 3: | t_xmin      | t_xmax | t_cid    | t_ctid |
│            | null bitmap | id=44  | name=... | ...    │
│ ← Tuple 2: | t_xmin      | t_xmax | t_cid    | t_ctid |
│            | null bitmap | id=43  | name=... | ...    │
│ ← Tuple 1: | t_xmin      | t_xmax | t_cid    | t_ctid |
│            | null bitmap | id=42  | name=... | ...    │
└───────────────────────────────────────────────────────┘

Pay attention to the header of each tuple. Before you even see your actual data (id=42, name=...), PostgreSQL stores 23 bytes of metadata:

  • t_xmin (4 bytes) — ID of the transaction that created the row
  • t_xmax (4 bytes) — ID of the transaction that deleted the row
  • t_cid (4 bytes) — command identifier within the transaction
  • t_ctid (6 bytes) — physical address of the newest version of the row
  • t_infomask, t_infomask2 (4 bytes) — visibility flags, HOT-update flags
  • t_hoff (1 byte) — offset to the user data

That's 23 bytes of overhead per row before your data even begins. And these 23 bytes aren't for you—they are for the MVCC (Multi-Version Concurrency Control) mechanism so that different transactions can see different versions of the same row concurrently.

Now add a NULL bitmap (1 bit per column in the table) and alignment padding. If your row has 10 columns, the null bitmap takes another 2 bytes. Total: 25+ bytes of overhead on a row you don't even control.

Query Path: How Many Hops?

When SELECT * FROM orders WHERE id = 42 is executed, the following happens:

Client              PostgreSQL                    Disk / Page Cache
  │                     │                              │
  │── TCP Packet ─────→ │                              │
  │                     │── parse SQL ──→ AST          │
  │                     │── plan query ──→ B-tree scan │
  │                     │                              │
  │                     │── 1. Find B-tree root ──────→│ (index page)
  │                     │                              │
  │                     │── 2. Traverse B-tree ───────→│ (2-3 pages)
  │                     │                              │
  │                     │── 3. Get ctid ──────────────→│ (leaf page)
  │                     │                              │
  │                     │── 4. Read heap page ────────→│ (data page)
  │                     │                              │
  │                     │── 5. Check visibility        │
  │                     │      (MVCC snapshot check)   │
  │                     │                              │
  │                     │── 6. Serialize to wire ─────→│
  │←── TCP Response ────│                              │

Six stages. A minimum of 4 page lookups (B-tree root, 1-2 intermediate nodes, leaf node, heap page). And every lookup is a potential CPU cache miss.


Part 2. How a Document-Oriented Database Stores Your Document

Now let's look at how a document-oriented database does this based on a hash table with memory mapping (mmap). We will look at MakoDB as an example, but the concepts apply to any similar engine.

The entire database is a single file mapped directly into the process's virtual address space via the mmap system call:

Process Virtual Memory
┌──────────────────────────────────────────────────────────┐
│ 0x00000000                                               │
│  ...                                                     │
│  Application code, stack, heap                           │
│  ...                                                     │
├──────────────────────────────────────────────────────────┤
│ 0x7F000000  ← mmap start                                 │
│  ┌────────────────────────────────────────────────────┐  │
│  │ dbHeader (48 bytes)                                │  │
│  │   Magic: "MAKODB\0\0"                              │  │
│  │   FreeOffset: 0x1A3F00  ← write new data here      │  │
│  │   NumBuckets: 5000000                              │  │
│  ├────────────────────────────────────────────────────┤  │
│  │ Hash Table (5,000,000 buckets × 48 bytes)          │  │
│  │   bucket[0]: hash=0, keyOff=0 (empty)              │  │
│  │   bucket[1]: hash=0xA3F1.., keyOff=0x1200..        │  │
│  │   bucket[2]: hash=0, keyOff=0 (empty)              │  │
│  │   ...                                              │  │
│  │   bucket[4999999]: hash=0xBB12.., keyOff=...       │  │
│  ├────────────────────────────────────────────────────┤  │
│  │ Data (keys + values, append-only)                  │  │
│  │   "tx:42"  → {"id":42,"name":"Mako","cost":12.5}   │  │
│  │   "tx:43"  → {"id":43,"name":"Ray","cost":8.1}     │  │
│  │   ...                                              │  │
│  │   ← FreeOffset                                     │  │
│  │                                                    │  │
│  │   [free space until end of file]                   │  │
│  └────────────────────────────────────────────────────┘  │
│ 0x7FFFFFFF  ← mmap end                                   │
└──────────────────────────────────────────────────────────┘

Query Path: One Hop

When a request for db.Get("tx:42") comes in:

Application                         mmap (RAM / Page Cache)
  │                                     │
  │── hash("tx:42") = 0xA3F10B2C        │
  │                                     │
  │── bucketIdx = hash % 5000000        │
  │   = 1730412                         │
  │                                     │
  │── offset = 48 + 1730412 × 48        │
  │   = 83059824                        │
  │                                     │
  │── *(bucket*)&mapped[83059824] ────→ │ ← ONE pointer
  │                                     │
  │   hash matched? keyLen matched?     │
  │   key bytes matched?                │
  │                                     │
  │── val = mapped[valOffset..+valLen]  │ ← ONE pointer
  │                                     │
  │   Done. JSON bytes in hand.         │

Two memory lookups. One arithmetic operation to calculate the bucket offset. One hash comparison. One read of the value at the offset. That's it.

No SQL parsing. No B-trees. No MVCC visibility checks. No serialization into a wire protocol.

This is why it takes 16 nanoseconds.


Part 3. The "Deep Window" Problem in Relational Databases

Now let's talk about something rarely mentioned in textbooks: performance degradation when shifting the pagination window.

Imagine you have an orders table with 10,000,000 rows, sorted by total_profit. You make the following query:

SELECT * FROM orders ORDER BY total_profit OFFSET 9999950 LIMIT 50;

Here is what happens in PostgreSQL:

B-tree Index on total_profit
┌──────────────────────────────────────────┐
│ Root                                     │
│  ├── Internal Node 1                     │
│  │   ├── Leaf Page 1 (rows 1-500)        │  ← Must traverse
│  │   ├── Leaf Page 2 (rows 501-1000)     │  ← Must traverse
│  │   ├── ...                             │  ← Must traverse
│  │   ├── Leaf Page 20 (rows 9501-10000)  │  ← Must traverse
│  │   └── ...                             │
│  ├── Internal Node 2                     │
│  │   ├── Leaf Page 21 ...                │  ← Must traverse
│  │   └── ...                             │
│  │                                       │
│  │    ... 19,998 pages skipped ...       │  ← ALL OF THIS IS READ
│  │                                       │
│  ├── Internal Node N                     │
│  │   ├── Leaf Page 19999                 │  ← Must traverse
│  │   └── Leaf Page 20000 (9999951-10M)   │  ← TARGET PAGE
│  └──                                     │
└──────────────────────────────────────────┘

PostgreSQL is forced to walk all 9,999,950 rows before it can give you the 50 you actually want. It cannot "jump" to the desired position in a B-tree because a B-tree is a tree of ordered keys, not a random-access array.

This is known as O(offset + limit) complexity. On the first page (OFFSET 0), the query flies. On the last page (OFFSET 9999950), it is 200,000 times slower.

How a Document-Oriented Database Handles This with a Pre-Sorted Index

In MakoDB, the sort:total_profit index is simply a flat array of 10,000,000 int32 numbers, where each number is the ID of a document sorted in ascending order of total_profit:

sort:total_profit (continuous int32 array in mmap)
┌───────────────────────────────────────────────────────────┐
│ offset 0     4     8     12    ...                        │
│ ┌─────┬─────┬─────┬─────┬─────────────────────────────┐   │
│ │ 482 │ 119 │ 7701│ 333 │ ...                         │   │
│ └─────┴─────┴─────┴─────┴─────────────────────────────┘   │
│   ↑                                                       │
│   ID of the document with the lowest total_profit         │
│                                                           │
│                    ... 10,000,000 elements ...            │
│                                                           │
│ ┌─────────────────────────────┬─────┬─────┬─────┬─────┐   │
│ │ ...                         │ 5512│  88 │ 4401│ 1002│   │
│ └─────────────────────────────┴─────┴─────┴─────┴─────┘   │
│                                        ↑                  │
│          ID of the document with the 9,999,950th profit   │
│                                                           │
│  offset = 9999950 × 4 = 39,999,800 bytes                  │
│                                                           │
│  ids[9999950] → 4401  ← INSTANT random access             │
│  ids[9999951] → 1002                                      │
│  ...                                                      │
│  ids[9999999] → 7788                                      │
└───────────────────────────────────────────────────────────┘

To fetch the page OFFSET 9999950, LIMIT 50, we only need to:

1. Calculate the byte offset in the array:
   byteOffset = 9999950 × 4 = 39,999,800

2. Read 50 elements (200 bytes):
   ids = mapped[39999800 : 39999800 + 200]

3. Retrieve the document for each ID:
   doc = db.Get("tx:" + ids[i])    // 16 ns × 50 = 800 ns

Total time: ~1 microsecond, regardless of whether you request the first page or the last page. O(1) complexity instead of O(offset).

Here is the degradation chart:

Response Time
     │
 10s │                                          ╱ PostgreSQL
     │                                        ╱   (B-tree scan)
  1s │                                      ╱
     │                                    ╱
100ms│                                  ╱
     │                                ╱
 10ms│                              ╱
     │                            ╱
  1ms│                          ╱
     │                        ╱
100μs│──────────────────────────────────────── MakoDB
     │                                         (array, O(1))
 10μs│
     │
  1μs│
     └──────────────────────────────────────────── OFFSET →
      0       2M       4M       6M       8M      10M

Part 4. Why an Array is Better Than a Tree (for Certain Tasks)

"But wait," an experienced DBA will say. "A B-tree supports insertions and deletions in O(log n), while an array requires O(n). Are you going to rebuild the array on every write?"

This is a completely fair point. And the answer is no, we don't rebuild it.

The Hybrid Approach: Array + Buffer (LSM Pattern)

We split the data into two layers:

┌─────────────────────────────────────────────────────────┐
│ Layer 1: RAM Buffer (Hot Data)                          │
│                                                         │
│  recentTransactions []Transaction                       │
│  ┌──────┬──────┬──────┬──────┬──────┐                   │
│  │tx:N+1│tx:N+2│tx:N+3│ ...  │tx:N+k│  ← k < 50         │
│  └──────┴──────┴──────┴──────┴──────┘                   │
│  Sorted in memory by the target sorting field           │
│  Insertion: O(1) append                                 │
│                                                         │
├─────────────────────────────────────────────────────────┤
│ Layer 2: On-Disk Array (Cold Data)                      │
│                                                         │
│  sort:total_profit  int32[10,000,000]                   │
│  ┌─────┬─────┬─────┬─────┬─────────────────────┐        │
│  │ 482 │ 119 │ 7701│ 333 │ ... 10M elements    │        │
│  └─────┴─────┴─────┴─────┴─────────────────────┘        │
│  Fully sorted. Random access O(1).                      │
│                                                         │
└─────────────────────────────────────────────────────────┘

        Reading (merge)
        ════════════════

     Disk (O(1) access)        RAM (k elements)
          │                        │
          │    ┌──────────────┐    │
          └───→│  Merge-Sort  │←───┘
               │  two streams │
               └──────┬───────┘
                      │
                      ▼
               Result (50 rows)

During reads, we merge the two sorted streams (the disk array and the RAM buffer) on the fly using a two-pointer algorithm. This works in O(offset + limit) relative to the disk array (a single linear pass), but with the array being O(1), we just jump directly to the target offset.

When the RAM buffer fills up (e.g., 50 elements), we perform a flush:

Flush (merging the buffer into the disk array)
──────────────────────────────────────────────

For each element in the buffer:
  1. Binary search the insertion position in the array
     (20 comparisons for 10M elements)
  2. Shift the tail of the array by 1 position
  3. Insert the element

50 elements × 20 comparisons = 1000 Get operations
+ array rewriting (40 MB) ≈ 5 ms total

Yes, a flush takes 5 milliseconds. But it only happens once every 50 writes. The amortized cost of insertion: 100 microseconds—very acceptable for read-heavy workloads.


Part 5. CPU Cache Lines: Why Contiguity is Everything

Now for the best part. Let's zoom in to the processor level and see why a flat array in mmap is physically faster than a B-tree.

A modern CPU does not read from RAM one byte at a time. It reads in cache lines of 64 bytes. When you access a single byte, the CPU loads the entire 64-byte block around it into its L1 cache.

B-Tree: Jumping Across Memory

B-Tree in Memory (simplified)
─────────────────────────────

Page A (root)                ← cache line loaded
  addr: 0x1000
  ├── key < 5000 → ptr: 0x8A000
  └── key ≥ 5000 → ptr: 0x12F000

       ↓ JUMP to 0x8A000
       ↓ (distance: 561 KB, ~8781 cache lines)

Page B (internal)            ← NEW cache line, L1/L2 miss likely
  addr: 0x8A000
  ├── key < 2500 → ptr: 0x3F2000
  └── key ≥ 2500 → ptr: 0x5A1000

       ↓ JUMP to 0x3F2000
       ↓ (distance: 3.4 MB, ~55000 cache lines)

Page C (leaf)                ← NEW cache line, L2 miss likely
  addr: 0x3F2000
  └── key=42 → ctid: (page 1881, offset 3)

       ↓ JUMP to heap page 1881
       ↓ (distance: UNPREDICTABLE)

Heap page 1881              ← NEW cache line, L3 miss likely
  addr: 0x750000
  └── tuple with row data

4 jumps, each over an unpredictable distance. The processor cannot predict (prefetch) the next address because it depends on the data (the key values inside the tree nodes). Each jump is a potential cache miss, costing:

  • L1 miss → L2 hit: ~5 ns
  • L2 miss → L3 hit: ~15 ns
  • L3 miss → RAM: ~60-100 ns

In the worst case: 4 × 60 = 240 nanoseconds spent just moving through memory, not including any actual calculations.

Flat Array: Sequential Access

int32 Array in mmap
───────────────────

  addr: 0x7F000000                          (mmap start)
  │
  │ offset = bucketIdx × 48                 (arithmetic, 0 ns)
  │
  ▼
  0x7F000000 + 83059824 = 0x842D3F10       (bucket address)
  ┌────────────────────────────────────┐
  │ bucket: hash, keyOff, valOff, ...  │    ← 48 bytes, 1 cache line
  └─────────────┬──────────────────────┘
                │
                │ valOffset = 0x1A3400     (from bucket)
                │
                ▼
  0x7F000000 + 0x1A3400 = 0x7F1A3400      (value address)
  ┌────────────────────────────────────┐
  │ {"id":42,"name":"Mako","cost":12.5}│    ← JSON bytes
  └────────────────────────────────────┘

2 memory lookups. Both addresses are calculated arithmetically (no data dependency), so the processor can prefetch them. When reading the sort:* array, data lies continuously, and the CPU loads them in 64-byte cache lines = 16 int32 elements in a single load.

Cache Line (64 bytes) → 16 int32 elements
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 482 │ 119 │ 7701│ 333 │ 8812│ 1003│ 5544│ 2271│  ← 32 bytes
├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ 9901│  42 │ 3387│ 6610│ 7004│ 1158│ 4429│ 8890│  ← 32 bytes
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
  ↑ A single RAM access brings in 16 document IDs

When scanning an array sequentially, the CPU utilizes its hardware prefetcher—special hardware circuitry that detects the sequential read pattern and starts pulling the next cache lines before your code even requests them.

A B-tree cannot benefit from this optimization because the address of the next node is unpredictable.


Part 6. The Problem of Deletions and How to Solve It

In an append-only store (which MakoDB is), there is a weak point: deleted records do not free up space immediately. The file only grows.

Append-Only File After Multiple Updates
┌─────────────────────────────────────────────────────────┐
│ key1=v1   │ key2=v2   │ key1=v1' │ key3=v3 │ key2=v2'   │
│ (old)     │ (old)     │ (new)    │ (active)│ (new)      │
│  ↑ garbage│  ↑ garbage│          │         │            │
└─────────────────────────────────────────────────────────┘

Old versions of key1=v1 and key2=v2 still occupy space. Buckets point to the new offsets (v1', v2'), but the dead bytes remain in the file.

Vacuum: Copying Active Data to a Clean File

The classic solution is compaction (called VACUUM in PostgreSQL, and compaction in LSM databases):

Vacuum (Compaction)
═══════════════════

Old File                         New File (vacuum_temp)
┌──────────────────────┐        ┌────────────────────────┐
│ key1=v1  (dead)      │        │                        │
│ key2=v2  (dead)      │  ───→  │ key1=v1' (active)      │
│ key1=v1' (active)    │  copy  │ key2=v2' (active)      │
│ key3=v3  (active)    │  ───→  │ key3=v3  (active)      │
│ key2=v2' (active)    │        │                        │
│ [40% garbage]        │        │ [0% garbage, compact]  │
└──────────────────────┘        └────────────────────────┘
         ↓
   os.Remove(old)
   os.Rename(temp → old)

MakoDB executes a vacuum automatically upon database startup. The process is:

  1. Open the old file as read-only.
  2. Create a new temporary file.
  3. Iterate over all active records (ForEach) and copy them to the new file.
  4. Close both databases.
  5. Delete the old file, rename the new one.

This happens transparently to the application. When the database is opened next, the file will be clean and compact.


Part 7. But Don't Relational Databases Have Hash Indexes Too?

Yes, PostgreSQL supports hash indexes (CREATE INDEX ... USING hash). But there is a crucial difference:

PostgreSQL Hash Index              MakoDB Hash Table
─────────────────────              ─────────────────

┌─────────────────────┐            ┌─────────────────────┐
│ Hash index page     │            │ mmap region         │
│  bucket → overflow  │            │  bucket → inline    │
│  pages → heap page  │            │  data right here    │
└──────┬──────────────┘            └──────┬──────────────┘
       │                                  │
       │ 3-4 levels of indirection:       │ 1-2 levels of indirection:
       │  1. hash bucket page             │  1. bucket struct
       │  2. overflow page(s)             │  2. value bytes
       │  3. heap page                    │
       │  4. MVCC visibility check        │
       │                                  │
       ▼                                  ▼
   ~200-400 ns                        ~16 ns

Key difference: in PostgreSQL, a hash index points to a ctid (the physical address of a row in the heap file). This is an extra level of indirection. Plus the MVCC check. Plus TOAST tables overhead for large values. Plus WAL logging.

In MakoDB, the bucket directly contains the offset to the value bytes in the same file. No intermediate structures. No visibility checks.


Part 8. The Cherry on Top: You Build Your Own Indexes

Everything I described above—hash tables, flat arrays, cache lines—is engine mechanics. But there is another advantage of document-oriented databases that is often overlooked. And it is probably the most powerful one.

Your application layer can independently create its own indexes and data structures—and work with them at system-kernel speeds.

In a relational database, you are limited to the set of indexes provided by the engine: B-tree, hash, GIN, GiST, BRIN. Each is a complex internal structure hidden behind the SQL interface. You can't look inside, you can't change the storage format, you can't optimize it for your specific task. You just write CREATE INDEX and hope the query planner does the right thing.

In a document-oriented KV store, the situation is fundamentally different. An index is just another key with a binary value. This means you can build any structures you can think of:

Examples of Custom Indexes in a KV Store
════════════════════════════════════════

1. Pre-sorted array of IDs (what we saw earlier):
   "sort:total_profit" → [482, 119, 7701, 333, ...]
   Format: flat int32[], O(1) pagination

2. Inverted index for full-text search:
   "idx:country:germany" → [4, 101, 502, 8841, ...]
   Format: array of document IDs containing "Germany"

3. Bitmap index for boolean filters:
   "bmp:is_vip" → 0110010011101...
   Format: bitset, AND/OR in nanoseconds

4. Histograms for analytics:
   "hist:price:0-100"   → 1482033
   "hist:price:100-200" → 892441
   Format: simple integers

5. Bloom filter for fast set membership checks:
   "bloom:emails" → [binary filter data]
   Format: custom binary structure

6. Spatial index (R-tree / geohash):
   "geo:u33dc0" → [id1, id2, id3, ...]
   Format: geohash prefix → array of coordinates

Notice: each of these indexes is a standard key → value pair in the same database. They lie right next to the documents themselves, in the same mmap file, in the same virtual memory space. Reading any index takes the same 16 nanoseconds as reading a document.

Why This Matters

In PostgreSQL, if you need a custom index (e.g., a bitmap index on a custom attribute or a spatial index with a custom grid), you have two choices:

  1. Write an extension in C (pg_extension)—months of work, debugging in the DBMS kernel, risking a crash of the entire cluster.
  2. Maintain a materialized view—extra complexity, latency in updates, duplicated data.

In a KV store, you simply do:

// Build an inverted index — one line
db.Put("idx:country:germany", serializeIDs(germanDocIDs))

// Build a bitmap — one line
db.Put("bmp:is_vip", vipBitmap.Bytes())

// Read index — 16 nanoseconds
ids, _ := db.Get("idx:country:germany")

No extensions. No DDL migrations. No query planner that might decide not to use your index.

The Trade-off: More Control, More Responsibility

Of course, you pay for this freedom. A relational database automatically updates indexes on every insert, update, and delete. In a document-oriented database, you decide:

  • When to update the index (synchronously on write? asynchronously in the background? in batches every N writes?)
  • What format to store it in (flat array? compressed bitset? delta encoding?)
  • How to handle concurrent updates (mutex? CAS? worker-based sharding?)

This requires more attention from the developer. But it yields a significantly better result because you design the data structure specifically for your workload, rather than adjusting to a generic B-tree that has to work "for everyone".

Universal Index (B-Tree)          vs     Specialized Index
────────────────────────                 ─────────────────

 ┌────────────────────┐                  ┌────────────────────┐
 │ Insert: O(log n)   │                  │ Insert: O(1)       │
 │ Search: O(log n)   │                  │ Search: O(1)       │
 │ Range:  O(log n)   │                  │ Range:  O(1)       │
 │ Overhead: ~40%     │                  │ Overhead: 0%       │
 │                    │                  │                    │
 │ Works for ANY      │                  │ Works for YOUR     │
 │ query pattern      │                  │ specific task      │
 └────────────────────┘                  └────────────────────┘

 Good choice when you                    Best choice when you
 DO NOT KNOW what queries                KNOW EXACTLY what queries
 you will run tomorrow.                  you will run tomorrow.

And this is the true essence of the document-oriented approach. It's not about "JSON vs tables." It's about removing the constraints of someone else's query planner and getting direct access to the hardware. With all the power and responsibility that brings.


Conclusion: When to Use What

┌────────────────────────────┬────────────────────┬────────────────────┐
│ Feature                    │ Relational (PG)    │ Document (KV)      │
├────────────────────────────┼────────────────────┼────────────────────┤
│ Point read by key          │ ~200-1000 ns       │ ~16 ns             │
│ Pagination OFFSET 0        │ ~1 ms              │ ~1 µs              │
│ Pagination OFFSET 10M      │ ~2-10 sec (!)      │ ~1 µs              │
│ JOIN of 3 tables           │ Fast (native)      │ Application level  │
│ ACID Transactions          │ Full               │ Reservation*       │
│ Concurrent writes          │ MVCC               │ Mutex / Sharding   │
│ Deletions & updates        │ In-place + VACUUM  │ Append + Vacuum    │
│ Ad-hoc queries             │ SQL, any logic     │ Key-based only     │
│ Custom indexes & structures│ Extension in C     │ Simple Put/Get     │
│ Overhead per row           │ 23+ bytes          │ 0 bytes            │
│ Network overhead           │ TCP + wire proto   │ 0 (embedded)       │
└────────────────────────────┴────────────────────┴────────────────────┘

* Transactions in document-oriented databases are very easy to implement using a "reservation" pattern: you write the intention of an operation to a separate key (e.g., txn:pending:12345), complete the steps, and then either commit (delete the pending key) or rollback (restore the previous values from the pending record). But that is a story for another time.

Document-oriented databases are not a silver bullet. They won't replace PostgreSQL for complex business logic with transactions and JOINs. But for tasks where:

  • Read workloads heavily dominate write workloads
  • Data is naturally represented as documents (JSON, BSON)
  • Predictable latency is needed across any pagination depth
  • There is no need for complex JOINs and database-level transactions
  • You are willing to design indexes specifically for your workload instead of relying on a generic planner

...they can be orders of magnitude faster.

Not because "NoSQL is cooler than SQL," but because a random-access array is physically faster than a tree of pointers when you don't need the properties of a tree. And because a specialized index built by you for your specific workload will always beat a generic one, if you know what you are doing.

The CPU doesn't lie. Cache lines don't lie. perf stat doesn't lie.


u/No-Job-5616 — 1 month ago
▲ 0 r/golang

Is it Possible to Build a High-Performance JSON Storage in Go (Without Code Gen or Reflection)?

JSON is slow to parse on one hand, but on the other -- it looks like the perfect format for dynamic data.

In this post I'll show what I ended up with when I tried to figure out whether it's possible to speed up data processing at the system level using only Go, combining two things that seem completely incompatible: a high-performance storage engine and the JSON format.

I picked up my virtual scalpel and started cutting away all the abstraction. I don't care about objects. I only care about information.

How It Was Done: Searching for Storage

First things first -- I needed to figure out how to store data on disk without bottlenecking on slow I/O and wasting resources on OS overhead for every read and write. I dug through a ton of options, and honestly, for this kind of task there's nothing better than the "mmap" (Memory-Mapped Files) system call.

The idea is simple: we take the database file and map it directly into the address space of our Go process.

  • To the CPU, the entire database looks like one big chunk of memory ("[]byte"). It doesn't even know there's a file on disk behind it.
  • When we read data, no system calls like read() happen at all. We just follow a pointer straight into the OS kernel's Page Cache.
  • If the page is already in RAM -- the CPU grabs the bytes in ~16 nanoseconds. If it's not -- the OS pulls it from disk in the background, and we don't have to lift a finger.

But Something Was Off: Standard JSON in Go

You'd think "mmap" would solve everything. But the speed was still underwhelming. I started digging -- and it turned out the bottleneck wasn't the disk at all. Go's native module ("encoding/json") can do everything under the sun: convert types, create custom parsers, validate structs. Everything except one thing -- work with information quickly. It parses JSON painfully slow.

That's because the standard parser runs heavy runtime reflection on every single call and creates a pile of objects on the heap for every string and every field.

I couldn't get rid of reflection entirely. But I managed to corner it: it fires exactly once at application startup (Warm-up), and after that it's completely gone from the hot path.

During initialization, we scan the Go struct once with reflection and memorize the memory offsets of all its fields via "reflect.StructField.Offset". We build something like a registry: which field lives at which address.

From that point on, everything flies without reflection:

  1. We run through the JSON bytes in a single pass, creating nothing on the heap.

  2. Found the right key -- instantly look up its offset in our registry.

  3. Pull the value straight from the JSON text and write it to the struct's memory address via "unsafe.Pointer":

    // Write value directly into struct memory at the pre-calculated offset fieldPtr := unsafe.Pointer(uintptr(structStartPtr) + fieldOffset) *(*int)(fieldPtr) = parsedIntVal

That's how lazy parsing was born. If a JSON document has 100 fields but we only need 2 -- the CPU just flies past the other 98. Result: up to 35,000,000 projections per second with zero allocations ("0 B/op").

What Else Does a Proper Storage Need?

Indexes, obviously. Without them any database turns into a dumb brute-force scan of everything.

We have two kinds of indexes:

  1. Inverted index for search (keys "idx:<token>" -> array of document IDs in binary form).
  2. Sort indexes (keys "sort:<field>" -> ordered array of IDs).

Here I stepped on an interesting rake. When you need to sort 20 million records by some field, the naive approach is to sort the entire array of structs. But each struct weighs ~168 bytes, and on every swap the CPU drags those 168 bytes back and forth. At 20 million records this becomes torture for the CPU cache.

The fix turned out to be simple -- lightweight pairs. Instead of heavy structs, we extract just the ID and the field value into a tiny 16-byte struct:

type SortPairFloat struct {
    ID  int32
    Val float64
} // Just 16 bytes!

16 bytes is exactly what the CPU can shuffle around in its registers without spilling out of the fast L1/L2 caches. Sorting 20 million records dropped from several minutes to ~1.5 seconds.

To avoid recalculating indexes on every write (that would be insane), we went with an LSM-tree approach:

  • New documents are appended to the end of the file (append-only, ~500 ns).
  • Fresh IDs accumulate in a buffer in RAM.
  • On reads, we merge the disk index and the buffer on the fly using two pointers (merge-sort).
  • When the buffer fills up (say, 50 items) -- we flush it to disk in a batch using binary search to find insertion points.

How Does JSON Fit Into Data Operations?

Here's the juicy part. JSON is stored in the database as-is -- raw bytes. And that gives us two serious wins:

  1. Lazy parsing: need 2 fields out of 100 -- we grab just those, straight from memory, no extra allocations.
  2. Zero-copy network delivery: when a client needs the full document over HTTP, we don't parse it and don't encode it back. We just take the byte slice ("[]byte") from "mmap" and write it straight to the socket. The CPU spends exactly zero time on marshalling.

How It All Works Together

+-----------------------------------------------------------------+
|                       Application / BFF                         |
|  [Transaction Buffer in RAM]    [Two-pass Merge Sort]           |
+--------------------------------+--------------------------------+
                                 |
                                 | (mmap / SHM memory mapping)
                                 v
+-----------------------------------------------------------------+
|                       MakoDB Storage                            |
|  Documents (raw JSON bytes):                                    |
|    - "tx:101" -&gt; {"id":101,"country":"Germany","cost":12.5}     |
|  Indexes (binary int32 arrays):                                 |
|    - "sort:cost"           -&gt; [15, 101, 88, ...]                |
|    - "idx:country:germany" -&gt; [4, 101, 502, ...]                |
+-----------------------------------------------------------------+

Key point: indexes are just data, same as the records themselves. You can throw anything into the database and it'll be stored as-is. Indexes live right next to the data under their own prefixes ("idx:" and "sort:") as dense binary arrays.

About Speed

The speed of this system is comparable to reading from RAM. Not from disk -- from actual RAM. Why?

  1. No middlemen: we removed network sockets, context switches, protocol parsing. The database is literally part of your application's address space.
  2. Page Cache: the OS keeps hot file pages in RAM on its own. We read at memory speed.
  3. Zero allocations: Go's garbage collector (GC) stays idle because we create nothing on the heap during queries.
  4. CPU cache friendly: data is packed tight and contiguous, the CPU doesn't have to jump around memory.

So, Is It Possible or Not?

Turns out, hell yes. And the results speak for themselves:

  • Lock-free reads (Get) in 16 nanoseconds (~60 million requests per second).
  • Lazy field projection (Query) in 27 nanoseconds (~35 million requests per second).
  • Search and sort across 20,000,000 documents -- under 6 milliseconds!

All of this in pure Go, without third-party code generators, and without heavy runtime reflection.

reddit.com
u/No-Job-5616 — 1 month ago
▲ 1 r/HiLoad

Is it Possible to Build a High-Performance JSON Storage in Go (Without Code Gen or Reflection)?

In this post I'll show what I ended up with when I tried to figure out whether it's possible to speed up data processing at the system level using only Go, combining two things that seem completely incompatible: a high-performance storage engine and the JSON format.

I picked up my virtual scalpel and started cutting away all the abstraction. I don't care about objects. I only care about information.

gist.github.com
u/No-Job-5616 — 1 month ago
▲ 1 r/HiLoad

Is it Possible to Build a High-Performance JSON Storage in Go (Without Code Gen or Reflection)?

JSON is slow to parse on one hand, but on the other -- it looks like the perfect format for dynamic data.

In this post I'll show what I ended up with when I tried to figure out whether it's possible to speed up data processing at the system level using only Go, combining two things that seem completely incompatible: a high-performance storage engine and the JSON format.

I picked up my virtual scalpel and started cutting away all the abstraction. I don't care about objects. I only care about information.

How It Was Done: Searching for Storage

First things first -- I needed to figure out how to store data on disk without bottlenecking on slow I/O and wasting resources on OS overhead for every read and write. I dug through a ton of options, and honestly, for this kind of task there's nothing better than the "mmap" (Memory-Mapped Files) system call.

The idea is simple: we take the database file and map it directly into the address space of our Go process.

  • To the CPU, the entire database looks like one big chunk of memory ("[]byte"). It doesn't even know there's a file on disk behind it.
  • When we read data, no system calls like read() happen at all. We just follow a pointer straight into the OS kernel's Page Cache.
  • If the page is already in RAM -- the CPU grabs the bytes in ~16 nanoseconds. If it's not -- the OS pulls it from disk in the background, and we don't have to lift a finger.

But Something Was Off: Standard JSON in Go

You'd think "mmap" would solve everything. But the speed was still underwhelming. I started digging -- and it turned out the bottleneck wasn't the disk at all. Go's native module ("encoding/json") can do everything under the sun: convert types, create custom parsers, validate structs. Everything except one thing -- work with information quickly. It parses JSON painfully slow.

That's because the standard parser runs heavy runtime reflection on every single call and creates a pile of objects on the heap for every string and every field.

I couldn't get rid of reflection entirely. But I managed to corner it: it fires exactly once at application startup (Warm-up), and after that it's completely gone from the hot path.

During initialization, we scan the Go struct once with reflection and memorize the memory offsets of all its fields via "reflect.StructField.Offset". We build something like a registry: which field lives at which address.

From that point on, everything flies without reflection:

  1. We run through the JSON bytes in a single pass, creating nothing on the heap.
  2. Found the right key -- instantly look up its offset in our registry.
  3. Pull the value straight from the JSON text and write it to the struct's memory address via "unsafe.Pointer":

&#8203;

// Write value directly into struct memory at the pre-calculated offset
fieldPtr := unsafe.Pointer(uintptr(structStartPtr) + fieldOffset)
*(*int)(fieldPtr) = parsedIntVal

That's how lazy parsing was born. If a JSON document has 100 fields but we only need 2 -- the CPU just flies past the other 98. Result: up to 35,000,000 projections per second with zero allocations ("0 B/op").

What Else Does a Proper Storage Need?

Indexes, obviously. Without them any database turns into a dumb brute-force scan of everything.

We have two kinds of indexes:

  1. Inverted index for search (keys "idx:<token>" -> array of document IDs in binary form).
  2. Sort indexes (keys "sort:<field>" -> ordered array of IDs).

Here I stepped on an interesting rake. When you need to sort 20 million records by some field, the naive approach is to sort the entire array of structs. But each struct weighs ~168 bytes, and on every swap the CPU drags those 168 bytes back and forth. At 20 million records this becomes torture for the CPU cache.

The fix turned out to be simple -- lightweight pairs. Instead of heavy structs, we extract just the ID and the field value into a tiny 16-byte struct:

type SortPairFloat struct {
    ID  int32
    Val float64
} // Just 16 bytes!

16 bytes is exactly what the CPU can shuffle around in its registers without spilling out of the fast L1/L2 caches. Sorting 20 million records dropped from several minutes to ~1.5 seconds.

To avoid recalculating indexes on every write (that would be insane), we went with an LSM-tree approach:

  • New documents are appended to the end of the file (append-only, ~500 ns).
  • Fresh IDs accumulate in a buffer in RAM.
  • On reads, we merge the disk index and the buffer on the fly using two pointers (merge-sort).
  • When the buffer fills up (say, 50 items) -- we flush it to disk in a batch using binary search to find insertion points.

How Does JSON Fit Into Data Operations?

Here's the juicy part. JSON is stored in the database as-is -- raw bytes. And that gives us two serious wins:

  1. Lazy parsing: need 2 fields out of 100 -- we grab just those, straight from memory, no extra allocations.
  2. Zero-copy network delivery: when a client needs the full document over HTTP, we don't parse it and don't encode it back. We just take the byte slice ("[]byte") from "mmap" and write it straight to the socket. The CPU spends exactly zero time on marshalling.

How It All Works Together

+-----------------------------------------------------------------+
|                       Application / BFF                         |
|  [Transaction Buffer in RAM]    [Two-pass Merge Sort]           |
+--------------------------------+--------------------------------+
                                 |
                                 | (mmap / SHM memory mapping)
                                 v
+-----------------------------------------------------------------+
|                       MakoDB Storage                            |
|  Documents (raw JSON bytes):                                    |
|    - "tx:101" -&gt; {"id":101,"country":"Germany","cost":12.5}     |
|  Indexes (binary int32 arrays):                                 |
|    - "sort:cost"           -&gt; [15, 101, 88, ...]                |
|    - "idx:country:germany" -&gt; [4, 101, 502, ...]                |
+-----------------------------------------------------------------+

Key point: indexes are just data, same as the records themselves. You can throw anything into the database and it'll be stored as-is. Indexes live right next to the data under their own prefixes ("idx:" and "sort:") as dense binary arrays.

About Speed

The speed of this system is comparable to reading from RAM. Not from disk -- from actual RAM. Why?

  1. No middlemen: we removed network sockets, context switches, protocol parsing. The database is literally part of your application's address space.
  2. Page Cache: the OS keeps hot file pages in RAM on its own. We read at memory speed.
  3. Zero allocations: Go's garbage collector (GC) stays idle because we create nothing on the heap during queries.
  4. CPU cache friendly: data is packed tight and contiguous, the CPU doesn't have to jump around memory.

So, Is It Possible or Not?

Turns out, hell yes. And the results speak for themselves:

  • Lock-free reads (Get) in 16 nanoseconds (~60 million requests per second).
  • Lazy field projection (Query) in 27 nanoseconds (~35 million requests per second).
  • Search and sort across 20,000,000 documents -- under 6 milliseconds!

All of this in pure Go, without third-party code generators, and without heavy runtime reflection.

reddit.com
u/No-Job-5616 — 1 month ago
▲ 1 r/HiLoad

Why JSON is secretly a high-performance DB format (when you strip away the abstraction layers)

We are used to thinking of JSON as a "slow" human-readable serialization format. We use it for REST APIs, configs, and web apps, but when it comes to high-performance databases, we immediately reach for Protocol Buffers, FlatBuffers, or custom binary layouts.

But what if we looked at JSON not from a human perspective, but from a machine's perspective?

If you strip away the heavy abstraction layers, standard JSON is highly dense, transparent, and phenomenally fast to process. In fact, you can build a database engine directly on top of JSON documents that runs reads in 16 nanoseconds and searches in under 0.5 milliseconds for over a million records.

Here is how we did it with MakoDB (a serverless, memory-mapped NoSQL Key-Value database written in Go).


📊 The Benchmarks (Hardware: AMD Ryzen 9 7950X3D)

Since MakoDB operates directly on raw JSON documents, here is the latency and throughput under parallel load (32 threads):

Operation Latency (ns/op) Throughput Allocations Description
Get 16.78 ns ~60,000,000 ops/sec 0 B/op Parallel Lock-Free Reads
Query 27.97 ns ~35,000,000 ops/sec 0 B/op Zero-alloc JSON fields projection
Put 484.50 ns ~2,060,000 ops/sec 0 B/op Parallel Writes (16 Shards)
Search 71.88 μs ~14,000 ops/sec 600 KB/op AND Multi-term Search (1000 hits)

MakoDB Code Examples: How to use it

MakoDB is a serverless daemonless library. You import it, open a database file, and start querying. Here are the core methods:

1. Initialization

// Open/create a sharded database: path, shards, max size, index buckets per shard
db, err := makodb.OpenSharded("mydb.db", 16, 15*1024*1024*1024, 6250000)
if err != nil {
    log.Fatalf("Failed to open DB: %v", err)
}
defer db.Close()

2. Writing (Put) and Lock-Free Reading (Get)

// 1. Put (Create / Update)
key := "user:101"
jsonDoc := []byte(`{"name":"Mako","age":25,"city":"Ocean","role":"admin"}`)
err := db.Put(key, jsonDoc)

// 2. Get (Lock-Free Read)
val, err := db.Get("user:101")
log.Printf("Document: %s", string(val))

3. Schema Projection & Querying

MakoDB uses the zero-allocation parser silentjson to project and extract fields directly from memory without deserializing the whole document. This runs at 35,000,000 queries per second:

type UserAgeQuery struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

var result UserAgeQuery
// Projects only specified fields directly out of raw JSON bytes
err := db.Query("user:101", &amp;result)

4. Full-Text Search (Inverted Index)

MakoDB supports indexing document text and performing fast multi-term intersection searches (AND queries) in microseconds:

docID := "doc:456"
bodyText := "Mako is an extremely fast memory-mapped JSON database"

_ = db.Put(docID, []byte(`{"id":"doc:456","body":"`+bodyText+`"}`))
_ = db.Index(docID, bodyText)

// Search returns matching document IDs
matches, _ := db.Search("mako database") // Returns ["doc:456"]

The Architecture: Direct mmap & Zero Abstractions

No magic, just mechanical sympathy. MakoDB maps database files directly into the virtual address space of your application via mmap. To the processor, the database is just a contiguous chunk of RAM. Key-value reads happen in nanoseconds (directly from the OS Page Cache), bypassing thread context-switches and network overhead.

Because the storage engine is daemonless and stores everything as raw JSON bytes, multiple processes (whether they are in Go, C++, Python, or PHP) can map and access the same database files simultaneously and safely.

+-----------------------------------------------------------------+
|                       Go BFF Application                        |
|   [ In-Memory Write Buffer ]      [ Two-Pointer Merge Engine ]  |
+-------------------------------+---------------------------------+
                                |
                                | (Zero-copy mmap / Shared Memory)
                                v
+-----------------------------------------------------------------+
|                  MakoDB Storage (Key-Value)                     |
|  Documents:                                                     |
|    - "tx:101" -&gt; {"id":101,"country":"Germany","cost":12.5}     |
|  Indices:                                                       |
|    - "sort:cost"           -&gt; tx:15,tx:101,tx:88...             |
|    - "idx:country:germany" -&gt; tx:4,tx:101,tx:502...             |
+-----------------------------------------------------------------+

Solving the Real-time Indexing Bottleneck (LSM-style)

In read-heavy setups, sorting on the fly is a bottleneck. We pre-sort the index on disk (creating lists of sorted IDs like sort:total_profit).

However, if you write new transactions to the database in real-time, rebuilding a 1,000,000-record index on every write would kill performance. To solve this, we implemented a hybrid in-memory buffer (LSM-style):

  1. Write Path: New records are saved to MakoDB and appended to an in-memory buffer recentTransactions. Writes complete instantly (~500 nanoseconds).
  2. Read Path: The query engine merges the pre-sorted disk index stream and the sorted in-memory stream using a two-pointer merge-sort algorithm on the fly.
  3. Lazy Comparisons: To merge them without loading the entire database, the engine lazily queries the database only for comparison values of the items inside the current pagination page.

When we flush the memory buffer to disk (either manually or when the buffer reaches 50 items), we perform a binary-search insertion for each new item in the sorted index array:

  • A binary search in a sorted array of 1,000,000 elements takes at most 20 comparisons.
  • For a batch of 50 new items: $50 \times 20 = 1000$ Get requests to MakoDB.
  • Each MakoDB read takes ~500 ns. The entire index flush completes in under 5 milliseconds on a standard SSD.

Try it yourself

We packaged the entire demo application -- including MakoDB, a 1M transaction mock generator, search/sort engine, and a web UI -- into a single self-contained binary using go:embed.

You can download the pre-compiled binary for Windows, Linux, or macOS and benchmark MakoDB on your own hardware:

Let me know what you think about this architecture! Is there room for optimization? How do you handle high-speed indexing in key-value setups?

u/No-Job-5616 — 1 month ago
▲ 3 r/HiLoad+1 crossposts

Achieving sub-microsecond similarity search on corrupted JSON using zero-allocation in-memory messaging (inspired by Alan Kay)

Hey r/HiLoad !

I wanted to share a fun technical experiment and a demo I built.

In the demo, a user uploads a large JSON data file, the system processes it in real-time, and search queries return results instantly. To test its resilience, I open the source file, intentionally corrupt one of the labels (introduce typos and break the format), copy this "broken" label, and paste it into the search box.

The system instantly locates that exact corrupted record, and then performs a similarity match to suggest normal, valid items. Finding similar entities is typically the most CPU-intensive part, yet it finishes in real-time, with sub-microsecond latency.

Here’s a breakdown of how it works under the hood, how Alan Kay’s design philosophy guided this, and the pragmatic engineering benefits.

The Paradigm Shift: Alan Kay's "Messaging" vs "Objects"

When building search or query layers, most Go developers make a default architectural choice:

  1. Parse the incoming JSON.
  2. Unmarshal/deserialize it into heavy Go structs (objects).
  3. Push them into database tables or search indexers.

This creates massive CPU overhead on JSON decoding, GC pressure from object allocation, and serialization roundtrips.

Alan Kay, the pioneer of OOP, once noted:

>“I'm sorry that I long ago coined the term 'objects' for this topic because it gets many people to focus on the lesser idea. The big idea is 'messaging' ... The key in making great and growable systems is much more to design how its modules communicate rather than what their internal properties and behaviors should be.”

So instead of focusing on mapping data to static internal struct representations ("objects"), we treated the JSON payload in memory as a series of raw messages. We query fields directly from raw bytes in-memory using silentJSON.

How the Pipeline Works

  1. Simple Disk I/O: Reading the raw file from disk is done with standard libraries. This is the only slow part of the process, and it was left standard intentionally to highlight the stark contrast: how slow traditional disk I/O is compared to what happens next.
  2. In-Memory Import: Once read, the data lives entirely in RAM.
  3. Zero-Allocation Byte Querying: The silentJSON module operates directly on raw JSON bytes. Instead of fully decoding the document into structs, it compile-checks schema keys and queries values directly out of the byte array. Zero allocations, minimal CPU cache misses.
  4. Similarity Matching on Corrupted Inputs: Even if the query is a damaged string, the engine finds the corrupted entry, uses it as a pivot, and performs similarity matching to pull normal, high-quality records from the index. Since we bypass object deserialization, all this heavy lifting takes nanoseconds.

Pragmatic Engineering Benefits

By moving away from heavy database layers and object mappings, we get several practical wins:

  • Minimal Cloud Spend: Similarity search is resource-heavy. By doing it directly on raw JSON bytes in memory with zero heap allocations, we can run millions of lookups on dirt-cheap, tiny VPS instances instead of expensive database clusters.
  • Better UX Resilience: Real-world data is messy. Users paste broken SKUs, typo-ridden names, or corrupted copy-paste clips. Being able to locate the exact corrupted reference instantly and suggest normal alternatives prevents dead ends.
  • Simplicity: The code is lightweight, daemonless, and relies on clean data communication rather than massive ORMs and schema migrations.

Would love to hear your thoughts on this approach! How do you handle high-throughput in-memory searches on raw payloads in Go?

Repository mentioned: silentJSON

u/No-Job-5616 — 2 months ago
▲ 1 r/HiLoad

Nexus: High-Performance Go Concurrency Primitives for Extreme Performance (SPSC, Sharded MPMC Mailbox)

Hey HiLoad community!

TL;DR: I've poured my heart into building a lock-free Go library that absolutely flies – it's up to 10 times faster than standard channels under heavy load (think 32+ cores). Dive in for the benchmarks and the "why" behind the architecture!

I'm super excited to share my latest project, Nexus! It's a Go library packed with high-performance, lock-free concurrency primitives. If you've ever found yourself hitting the limits of standard Go channels or mutexes when you need really high throughput and ultra-low latency, then Nexus might just be your new best friend.

So, what's the big deal? When you're pushing systems to their absolute maximum (especially on those beefy multi-core processors, 32+ cores and beyond!), standard Go synchronization tools like mutexes and channels can actually become the bottleneck. All those atomic operations on shared head and tail counters can lead to a ton of contention for cache lines (what we call "false sharing"), which just grinds performance down and kills scalability.

My Solution: Nexus Nexus is built with a deep understanding of "mechanical sympathy" – meaning it's designed to work with the hardware, not against it. Here's what it brings to the table:

  • Truly Lock-Free Design: We're talking pure atomic operations here, which means minimal kernel context switches. It's all about speed!
  • Decentralized Architecture: Especially cool in the ShardedMailbox, there are no central counters or "hint" variables, which eliminates bottlenecks and lets performance scale beautifully with every extra core you throw at it.
  • Cache-Friendly: I've carefully padded the data structures to prevent false sharing, keeping your CPU caches happy and efficient.

What's inside the box?

  1. spsc: This is a rock-solid, lock-free Single-Producer-Single-Consumer (SPSC) queue. Perfect for those tight pipelines where one goroutine is churning out data and another is gobbling it up.
  2. sharded: Meet the Sharded Mailbox! This isn't your grandma's queue. It's a collection of single-element "mailboxes" (shards). It's specifically engineered for super high-throughput message exchange between many producers and many consumers (MPMC).
    • How does the ShardedMailbox work its magic? Each shard is like its own little independent "mailbox" with a simple "empty" or "full" state. Producers and consumers play a clever "casino" strategy: they start by checking their "home" shard, and if that's busy, they gracefully move to adjacent ones. This smartly distributes the load and avoids those nasty "hot spots." The Go runtime then naturally balances everything out: faster goroutines snatch up free/full shards quicker, while slower ones just wait a bit longer. The result? A beautiful self-balancing act where your most performant cores get to do the most work!

Let's talk numbers! Benchmarks (on an AMD Ryzen 9 7950X3D 16-Core Processor)

ShardedMailbox (MPMC) vs. a standard channel:

  • At 4 cores: The ShardedMailbox is already ~2.4 times faster than a standard channel.
  • At 32 cores: This is where Nexus really shines! The ShardedMailbox is an incredible ~10.5 times faster than a standard channel. Talk about scalability!

SPSCQueue (SPSC) vs. a standard channel:

  • At 4 cores: Even in a simple single-producer-single-consumer setup, SPSCQueue is ~10.6 times faster than a standard channel.

So, when should you reach for Nexus?

  • Latency is your enemy: If 50-100 ns of channel latency feels like an eternity.
  • Concurrency is off the charts: When you're dealing with 32, 64, or even 128 cores, and standard mutexes are just fighting each other for cache lines.
  • Predictability is key: You want to avoid those annoying GC "freezes" caused by unnecessary allocations.

Real-world Use Cases (where Nexus truly shines):

  • High-Frequency Trading (HFT): Every microsecond counts when you're transferring market quotes or order execution signals.
  • Real-time Game Event Processing: Imagine updating a game world with thousands of players' actions – Nexus keeps things smooth.
  • Database and Storage Engines: Perfect for implementing things like Write-Ahead Logs (WAL) or LSM-tree structures.
  • Network Proxies and API Gateways: Handling tens of thousands of requests per second? Nexus can build lock-free pipelines that handle immense loads.
  • AI/ML Inference Pipelines: Feeding data to your models without CPU-side bottlenecks. If your model is fast but data transport is slow, Nexus is your answer.

When might you stick with standard Go channels?

  • Low-load applications: For a simple web service with, say, 10 requests per second, standard channels are perfectly fine and often simpler to maintain.
  • Complex select logic: Go channels are unique for their select statement, letting you wait on multiple events. Nexus is more of a "super-fast pipe"; it's all about direct data exchange, not complex selection logic.

Quick Code Example (Sharded Mailbox):

package main

import (
	"fmt"
	"sync"

	"github.com/GenshIv/nexus/sharded"
)

func main() {
	mailbox := sharded.NewShardedMailbox[int]()

	var wg sync.WaitGroup
	numMessages := 100

	wg.Add(numMessages * 2)

	fmt.Printf("Launching %d producer-consumer pairs on %d shards...\n", numMessages, mailbox.ShardCount())

	for i := 0; i &lt; numMessages; i++ {
		go func(consumerID int) {
			defer wg.Done()
			item, err := mailbox.Dequeue(uint64(consumerID))
			if err != nil {
				fmt.Printf("Consumer %d failed: %v\n", consumerID, err)
				return
			}
			fmt.Printf("Consumer %d received: %d\n", consumerID, item)
		}(i)
	}

	for i := 0; i &lt; numMessages; i++ {
		go func(producerID int) {
			defer wg.Done()
			item := 1000 + producerID
			err := mailbox.Enqueue(uint64(producerID), item)
			if err != nil {
				fmt.Printf("Producer %d failed: %v\n", producerID, err)
				return
			}
			fmt.Printf("Producer %d sent: %d\n", producerID, item)
		}(i)
	}

	wg.Wait()
	fmt.Println("\nAll messages have been successfully exchanged.")
	mailbox.Close()
}

Installation is a breeze: go get github.com/GenshIv/nexus

I'd absolutely love to hear your thoughts, questions, and any suggestions you might have! Feel free to give Nexus a spin in your own projects and definitely share your results.

Check out the GitHub repo here: https://github.com/GenshIv/nexus

u/No-Job-5616 — 2 months ago
▲ 3 r/HiLoad

silentjson v2.0.0: Hitting the hardware limits, or how we squeezed the maximum out of JSON parsing in Go

Hello community! We are excited to announce the major update of our JSON parser without code generation and allocations -- silentjson v2.0.0.

First of all, a huge thank you to everyone who supported the project and provided feedback. We wouldn't have come this far without your involvement.

What is new in v2.0.0?

In this release, we focused on ultimate efficiency and specific high-load use cases:

  • Scalar parsing optimization (non-AVX2). We have significantly improved the scalar fallback. Now, even on architectures without modern vector instruction support or where they are disabled, the parser shows excellent results.
  • hft-ipc. Those who know, know.

The icing on the cake: hitting the physical limits

The main news of the release -- the speed has accelerated so much that we simply stopped being able to catch up. We have practically eliminated software overhead and are now simply hitting the raw hardware and memory bandwidth.

It is cool, yes. But we have to be honest: an extremely heavy load has an impact on us as well. To be fair, in a stress scenario simulation under heavy load, the parsing speed dropped by 60%. But even with this drop, silentjson continues to outperform all competitors by an insurmountable margin. The safety margin turned out to be colossal.

Here is a small teaser with tests on AMD Ryzen 9 7950X3D (parsing 100,000 objects):

Mode Throughput
Standard (encoding/json) 110 MB/s
Sonic (JIT) 644 MB/s
SilentJSON (Scalar) 810 MB/s
SilentJSON (AVX2) 24 670 MB/s

(Details later or on GitHub)

Do not take our word for it

Do not forget about our contribution and go check if we have deceived you. Just in case.

Update to the latest version:

go get github.com/GenshIv/silentjson@v2.0.0

Post your benchmarks in the comments to this article. Who can do more!

Project Repository: github.com/GenshIv/silentjson

reddit.com
u/No-Job-5616 — 2 months ago
▲ 2 r/HiLoad

How to make money on the CPU. Why do we sell features instead of solutions?

Optimization is always good, of course. But let's be honest: the business needs a product. Here and now. The business rarely thinks about what comes "later". That's why they hire a team of developers and demand features. They need it written faster, shipped faster, and sold faster.

But you don't have to sell just products or features. You can sell solutions.

Imagine a classic scenario: the business has grown, and traffic has skyrocketed (or we started selling 5 times more cupcakes). I had a real case in my practice. They come to me and say: "We urgently need to buy 3 more servers, we can't handle the load". And I look at the metrics and reply: "What for? Our current servers are only at 20% capacity".

Has this ever happened to you? Usually, it's the exact opposite. Developers themselves are begging: "Give us more hardware, our microservices are suffocating".

Now imagine a different dialogue: Business: -- Why did we even buy such an expensive server if it's sitting practically idle right now? You: -- Because the conditions and architecture were different back then. Now, we've made the code run efficiently.

Do you know what the main problem is? The business buys "features", even though it actually wants to buy a solution. But very few people actually sell those solutions. Most often, developers just sell "delayed headaches" with the condition of buying new hardware and endlessly scaling horizontally.

It's hard to blame the programmers here. We all live in a certain engineering culture where abstractions take center stage, and understanding how the hardware actually works is considered "low-level" and unnecessary. Let's look at how we got here through three famous quotes.

>"Software is getting slower more rapidly than hardware becomes faster." -- Niklaus Wirth, creator of Pascal (Wirth's Law, 1995).

In the race for development speed, we started hiding the hardware behind thick layers of frameworks, virtual machines, and Garbage Collectors. We are "burning" all the massive computing power that modern multi-core processors give us just to maintain these abstractions. Hardware gets more powerful every year, but programs run slower and slower for the end user.

>"We broke monoliths into microservices to solve management problems, not performance problems." -- Kelsey Hightower, one of the main evangelists of Kubernetes.

Many young developers believe that microservices are synonymous with HighLoad and speed. In reality, we took a lightning-fast local memory call (which took nanoseconds) and replaced it with a slow network request with endless JSON serialization (which takes milliseconds). The industry voluntarily traded pure CPU speed for insane network latency.

And finally, we got way too carried away with trendy design patterns:

>"I made up the term ‘object-oriented’, and I can tell you I did not have C++ in mind." -- Alan Kay, one of the creators of Smalltalk (OOPSLA '97).

A bit later, he added:

>"I’m sorry that I long ago coined the term “objects” for this topic... The big idea is messaging."

We somehow unnoticeably got carried away with beautiful objects, abstract factories, and layers, completely forgetting how the system actually communicates internally and how this code will eventually be executed in silicon.

But there is another way. We can "sell" servers to the business without physically selling them. Even servers that haven't been bought yet!

The point is, if you deeply study your domain, you can strip away all the architectural fluff. You can implement your idea so that it eats microscopic CPU ticks, rather than entire server racks. Essentially accelerating the system and reducing resource consumption not just by a factor of ten, but by hundreds of times.

Engineering is when your code works in synergy with the processor, not fights against it.

Join our community at r/HiLoad. Let's find elegant solutions, not just "ship features".

And now, a question for you: How often have you managed to convince the business when they asked to "just buy more hardware", instead of giving you time to find and optimize the bottleneck in the architecture? Share your stories!

reddit.com
u/No-Job-5616 — 2 months ago
▲ 1 r/HiLoad

Ditching ML for Math: How we packed a catalog of millions of products into an int64

Hey r/HiLoad,

I wanted to share a war story from a past project. We were dealing with a classic nightmare: receiving millions of products from suppliers and having to classify them instantly as their price lists came in.

Because it was the trendy thing to do, we initially threw a neural network at the problem. In production, it was actually pretty snappy. But the training phase? An absolute dumpster fire. The human factor completely ruined it because the team responsible for managing the catalog and keywords was frankly just phoning it in.

They kept feeding absolute garbage into the training set. Naturally, the model choked on this chaos and started hallucinating wildly. My personal favorite was when it confidently classified a massive batch of toilet paper into the "Baby Car Seats" category. You can imagine the collective groan from the dev team when we had to stay up half the night manually unscrewing thousands of misclassified items after a "successful" release.

To make it all worse, retraining this beast took up to two weeks. And the punchline? Because the catalog team never stopped making mistakes, we had to kick off this miserable two-week process almost every single month. Two weeks of training, maybe two weeks of peace, then someone uploads garbage again, paper towels suddenly belong in Auto Parts, and we're back in hell.

We desperately needed a solution that was dead-simple, rock-solid, easy to debug, and -- most importantly -- didn't need two weeks to learn what a car seat is. The fix turned out to be surprisingly elegant: we ripped out the ML magic entirely and replaced it with pure math.

From Words to Numbers: The Magic of Overflow

The core idea was to turn any product description into a tiny, numerical "fingerprint."

The pipeline was stupidly simple:

  1. Take the raw product name.
  2. Strip out all the garbage (stop words, prepositions).
  3. Run it through a stemmer to just get the root words.
  4. Hash each meaningful root into an int64.

Here’s the fun part. We didn't store an array of hashes. We just added them all together using standard binary addition. The trick here is that when you add large numbers in int64, you get a natural overflow, which just drops the most significant bits. In Go (and C/C++), this is completely legal, costs zero CPU cycles, and happens instantly.

The code looks almost too simple:

import "hash/fnv"

// Hash a single cleaned root word
func hashWord(word string) int64 {
	h := fnv.New64a()
	h.Write([]byte(word))
	return int64(h.Sum64())
}

// Get the numerical fingerprint for the whole product name
func getProductFingerprint(tokens []string) (hashSum int64, wordCount int) {
	for _, token := range tokens {
		// Stop-word filtering and stemming happens around here
		if isStopWord(token) {
			continue
		}
		
		wordHash := hashWord(token)
		
		// Standard binary addition. 
		// The int64 overflows naturally—no panics, no worries.
		hashSum += wordHash 
		wordCount++
	}

	return hashSum, wordCount
}

Just like that, we packed the "meaning" of an entire product name into exactly one int64 -- regardless of whether it had three words or ten. We'd also generate a few of these fingerprints per product to cover permutations and weird supplier spellings.

The Index and Foolproofing

That int64 became our key. For lookups, we just threw it into a standard in-memory Map. The value was a tiny struct:

  1. ID of the target category or product model.
  2. Word Count -- the exact number of tokens that built this hash (that wordCount variable from the code).

Why keep the word count? That was our bulletproof vest against collisions and our secret weapon for smart ranking.

When a new item came in from a price list, we'd run its name through the pipeline and hit the Map. If the int64 matched, we absolutely had to check if the word counts matched too. This took the chance of a false collision (two different sets of words accidentally summing to the same hash) down to mathematical zero.

During a lookup, we'd generate fingerprints for various combinations of words in the name. If we got multiple hits, the category that matched the highest number of words won. Longer phrase = more accurate match.

Trust, but Verify (The Dev-Tool)

Given our PTSD from the catalog team (never forget the toilet paper car seats), we refused to do any more blind debugging. If an item went to the wrong category, we wanted receipts.

So, we built an internal Dev-Tool. You paste a product name in, and it spits out the exact thought process: how it was tokenized, the stemmed roots, the int64 hashes, and crucially -- which exact words matched the database and which were ignored.

You can't argue with transparent math. Whenever someone complained about a misclassification, we could find the culprit (usually a horribly written keyword rule) in two clicks, rather than spending hours trying to decipher neural network logs.

Performance

The numbers were great. Even with 5 million unique products (which balloons to tens of millions of records when you add the hash variations), the whole index (int64 Key + Category ID + Word Count) barely took up a couple hundred megabytes.

The entire thing lived comfortably in RAM. Classifying a single item meant a couple of fast hashes and an O(1) map lookup. We were chewing through incoming price lists at the speed of the network interface while the CPU basically sat there twiddling its thumbs.

Scaling it out

Since it was just a Map, it scaled horizontally like a dream. We just distributed the heavy price list streams across worker goroutines. No DB transactions, no table locks, no massive SQL queries -- just pure arithmetic and memory reads.

We also reused this for validation. If a supplier sent a product and its fingerprint matched a model we already knew, the system instantly recognized it as a duplicate. We just attached the new price to the existing item and kept the catalog completely clean.

TL;DR: ML isn't always the answer. Sometimes, adding a bunch of hashes together and letting the integer naturally overflow gives you an instantly searchable, O(1) in-memory index that takes up almost no RAM and doesn't take two weeks to train.

Curious to hear from you guys -- how would you handle cache invalidation for an in-memory setup like this if the business decides to change the stop-word dictionary or stemming rules on the fly? Would love to hear your thoughts.

reddit.com
u/No-Job-5616 — 2 months ago
▲ 2 r/HiLoad+2 crossposts

Accelerating Go: How we beat GNU Grep and reached 7.3 GB/s without a single line of Assembly

Many people think Go is a typical language for enterprise, form building, and boring microservices. And they are somewhat right. But unlike their perspective, we understand what hides under the hood if you dig a little deeper.

A very common task: exact search of multiple strings in a huge file. Who hasn't done this? We constantly use it for logging and analysis. For example, GNU grep is a great program. It has regex, word variations, and speed. So, what am I talking about? Ah yes... You haven't seen real speed yet.

Today, we are going to search for a dictionary of 50,000 unique keys in a log file of exactly 1 Gigabyte. And we will do it in Go.

1. Naive approach: Writing the file loop

First, let's sketch out the core. We will read the file not line-by-line (which kills the garbage collector), but in fat 10 Megabyte chunks using bufio.Scanner.

func (m *Matcher) MatchReader(reader io.Reader) {
    scanner := bufio.NewScanner(reader)
    buf := make([]byte, 10*1024*1024)
    scanner.Buffer(buf, 10*1024*1024)

    for scanner.Scan() {
        lineBytes := scanner.Bytes()
        // ... substring search
    }
}

2. Setting up output and callbacks

To make our library universal, we won't write fmt.Println directly in the core. We will make an elegant Callback:

type MatchCallback func(lineNum int, bytePos int, pattern []byte, lineContent []byte)

Now, upon every match, we will pass the line number and the found word to the outside.

3. Setting up parameters (Two-byte hash)

How do we search for 50,000 words simultaneously? Checking each word in a loop is death (we'd get 50 Terabytes of scanning). Write an Aho-Corasick tree? Long, complicated, and, running ahead—unnecessary.

We take the first 2 bytes of each search word, convert them into a uint16, and create an array of 65,536 buckets.

type Matcher struct {
    buckets [65536][][]byte
}

Upon initialization (which takes a laughable 2 ms), we distribute the 50,000 words into these buckets. On average, there is less than one word per bucket!

4. Measuring the first version (Single thread, no unsafe)

In the loop, we simply take a 2-byte window and look into the buckets[idx] bucket. If it's not empty, we compare the remaining word via bytes.Equal.

Running the benchmark:

BenchmarkMatcher-32      100      10927238 ns/op       959.60 MB/s

~1 GB/s. Good, but not enough. We want to squeeze all the juice out of the hardware!

5. The dark side of Go: Adding unsafe and multithreading

Now buckle up. We remove the safety checks of the Go compiler.

Instead of idx := uint16(line[i]) | uint16(line[i+1])&lt;&lt;8, which causes an array bounds check on every byte, we write:

ptr := unsafe.Pointer(&amp;data[0])
idx := *(*uint16)(unsafe.Pointer(uintptr(ptr) + i))

What happens from the Assembly (ASM) perspective? The Go compiler takes the hint and collapses this piece into one single assembly memory read instruction (like MOVZX), executed in one clock cycle!

Next—CPU Cache. We add hasPattern [65536]bool to the structure. This array weighs exactly 64 Kilobytes—it perfectly, byte-for-byte, fits into the ultra-fast L1 cache of modern processors. We no longer touch RAM at all!

And finally—Multithreading with smart queues. We stream the file, slice it by the last newline \n (so as not to cut keys in half at chunk boundaries), and throw the pieces into taskChan. Workers process them in parallel, incrementing the result via atomic counters: atomic.AddInt64(&amp;matchesCount, 1).

6. Measuring and staying in shock

Running our new pure in-memory benchmark (go test -bench . -benchmem):

goos: windows
goarch: amd64
pkg: grep/deanon
cpu: AMD Ryzen 9 7950X3D 16-Core Processor          
BenchmarkMatcher-32              100      10927238 ns/op       959.60 MB/s      10547080 B/op        126 allocs/op
BenchmarkMatcherParallel-32       97      14240700 ns/op      7363.23 MB/s      62197060 B/op       1169 allocs/op

7.36 Gigabytes per second! At the same time, the algorithm practically doesn't touch the garbage collector.

Out of curiosity, we take the native C GNU grep (v3.0 with Aho-Corasick support) and sic it on our 1GB log file (NVMe SSD).

7. Final comparison

Below are the results of a real scan of the target.log file of 1 Gigabyte (search with -F -f flag for grep and MatchReaderParallel for our Go code).

Results Table (Dictionary of 10,000 keys)

Tool Threads Scan Time (1 GB) Throughput
GNU Grep 3.0 1 (Single) ~0.62 sec ~1.6 GB/s
Our Go (Base) 1 (Single) ~0.95 sec ~1.0 GB/s
Our Go (Unsafe) 32 (Multi) 0.29 sec ~3.4 GB/s 🏆

Results Table (Dictionary of 50,000 keys)

The larger the dictionary, the more classic search suffers due to the growth of data structures. But our hash array has a fixed size (64 KB), so the speed hardly drops!

Tool Threads Scan Time (1 GB) Throughput
GNU Grep 3.0 1 (Single) 1.39 sec ~0.7 GB/s
Our Go (Base) 1 (Single) 1.10 sec ~0.9 GB/s
Our Go (Unsafe) 32 (Multi) 0.32 sec ~3.1 GB/s 🏆

(Note: On a real file, we hit a bottleneck at 3.1 GB/s, as this is the physical read speed limit of our SSD drive. In RAM, as seen from the benchmarks, the algorithm is capable of 7.3 GB/s).

Reflections: Why do we need "Aho-Corasick"?

The standard Aho-Corasick algorithm for multi-search builds a huge transition graph in RAM. For 50k words, that's hundreds of thousands of pointers. Running through this graph, you constantly catch Cache Misses. Our "stupid" approach with a 2-byte index array and direct access via unsafe puts the filter directly into the CPU L1 cache and destroys any trees.

Conclusions

In our core, there is not a single line written manually in Assembly (we also didn't use full-fledged SIMD via C-bindings). We stayed within the standard Go tooling.

But if you study the tool you work with, you can work miracles. In C/C++ it would have been much harder and not necessarily faster: managing buffer pools, cross-platform work with channels and goroutines—in Go, this is done out of the box. We operate directly on the hardware (via unsafe), but at the same time completely and safely manage high-level queues and multithreading.

Don't be afraid to look under the hood!

Repo if anyone wants to poke around the assembly: https://github.com/GenshIv/grep

u/No-Job-5616 — 2 months ago
▲ 86 r/HiLoad+2 crossposts

How to split 10GB JSON files in seconds without hitting RAM limits

We had this classic pain point on our project: constantly chewing through massive JSON arrays. Catalogs, analytics dumps, ML datasets — files ranging from a couple hundred megabytes to tens of gigabytes.

The task was stupidly simple: split a giant JSON array into individual elements so we could chunk them or throw them into parallel processing. No data transformation, no querying by keys. We literally just needed to find where each chunk starts and ends.

Naturally, we started with the classic approach: json.Unmarshal -> slice -> json.Marshal. On a 10GB file, memory consumption went to the moon. We ended up spending more time fighting the Go garbage collector (GC) than doing actual work.

And then it clicked: to just move the data around, we don't need to understand what's inside it. We just need to find the boundaries.

Stop parsing, start scanning

Every parser out there (even the ultra-fast ones like sonic or simdjson) still builds a tree in memory. Instead, you can just treat the JSON as a raw byte stream. Look for structural markers, find the edges, and cut.

The entire logic boils down to a tiny state machine:

  1. Nesting counter: { and [ go +1, } and ] go -1.
  2. String tracking: keep track of when you enter "..." so you don't accidentally react to brackets inside a text field.
  3. Escapes: a \" inside a string is a trap, not the end of the string.
  4. The boundary: whenever your nesting depth is exactly 0, any comma , is where you split.

That’s it. We don't care about keys or values. We don't allocate a single byte, we just return memory views (slices) of the original buffer.

Here’s what the concept looks like in Go (oversimplified, ignoring string logic):

gofunc findElements(data []byte) []Chunk {
    var chunks []Chunk
    depth := 0
    start := 0
    for i, b := range data {
        switch b {
        case '{', '[':
            depth++
        case '}', ']':
            depth--
        case ',':
            if depth == 0 {
                chunks = append(chunks, Chunk{Start: start, End: i})
                start = i + 1
            }
        }
    }
    if start &lt; len(data) {
        chunks = append(chunks, Chunk{Start: start, End: len(data)})
    }
    return chunks
}

Obviously, this naive code will break on the first tricky whitespace or string, but you get the point. We aren't parsing. We are scanning.

Why is this so damn fast?

  1. Zero allocations in the hot loop. You're just handing back data[start:end]. No new objects, no copying strings, no building hash maps.
  2. Hardware absolutely loves it. Your entire working state is basically two integers. It easily fits in L1 cache, and memory reads are strictly sequential.
  3. The branch predictor is happy. A simple state machine with highly predictable transitions is infinitely easier for the CPU to digest than a full parser juggling dozens of token types.

Look at how much work we are skipping:

Step Standard Parser Boundary Scanner
Read bytes
Classify tokens Only {}[]"\ and ,
Build hash maps
Allocate strings
Allocate slices
Type conversion
What you get back []MyStruct [][]byte (pointers to original buffer)

We are literally throwing away 80% of the overhead.

But how fast is it actually?

I got a bit carried away and polished this into a production-ready tool. I added proper string handling, escape tracking, and rewrote the hot loop in AVX2 assembly (chewing through 32 bytes per cycle using SIMD bitmasks).

Tbh, the results surprised even me:

Approach What it does Throughput Memory Overhead
encoding/json Full parse → Go structs ~107 MB/s 3-4x input size
sonic / simdjson-go Optimized parse → structs/AST ~400–700 MB/s ~1.1x
My AVX2 scanner Just finds boundaries ~4.1 GB/s ~1.0x (zero extra)

At 4.1 GB/s, the algorithm isn't even the bottleneck anymore. It's bottlenecked by the RAM's read bandwidth. The CPU is just sitting there waiting for the next cache line to arrive.

The catch (Tradeoffs)

Because I went the unsafe and raw assembly route for maximum speed, you have to pay the price:

  • Platform-specific: The AVX2 branch only works on amd64. For ARM (hello MacBooks), you need a pure Go fallback.
  • Memory lifecycle danger: You are getting slices that point directly to the original buffer. If that []byte gets overwritten or GC'd while you're still working with the chunks... it's going to hurt.
  • No validation: The scanner takes your word that the JSON is valid. Feed it garbage, and it will silently slice up garbage.

TL;DR

The biggest insight was stupidly simple: stop thinking "I need to parse this JSON" and start thinking "I need to find boundaries in a byte stream". Once I changed my perspective, the code wrote itself and the performance gap was massive.

Has anyone else suffered through this? How do you guys route or chunk massive JSON payloads in production when you simply can't fit them into RAM?

If anyone wants to poke around the assembly or run the benchmarks, the repo is here: https://github.com/GenshIv/silentjson

u/No-Job-5616 — 2 months ago
▲ 1 r/HiLoad

Welcome to r/HiLoad: Why we are here (and what to post)

If you’ve ever spent days hunting down a memory leak, fought the Garbage Collector in production, or rewritten a perfectly readable function into an ugly, unsafe assembly kernel just to squeeze out an extra 500 MB/s of throughput — welcome home.

I created r/HiLoad because I felt something was missing in the standard programming subreddits. Too often, deep technical posts about performance optimization or niche architectural choices are met with "just use the standard library," or worse, removed by moderators for "self-promotion."

Here, the rules are simple and built for engineers:

1. Performance over purity We respect beautiful code, but we respect production metrics more.

2. Bring the receipts (Data & Benchmarks) We love flame graphs, allocation metrics, and throughput numbers. We love data.

3. Show and Tell (Yes, you can share your projects!) If you built a crazy fast tool, library, or pipeline — share it! Self-promotion is absolutely allowed and encouraged here, as long as you provide technical context. Tell us the problem, how you solved it, the tradeoffs you made, and what didn't work.

What to post:

  • Deep dives into backend architecture at scale.
  • Optimizing CPU, Memory, or I/O.
  • Database scaling and query tuning.
  • "War stories" from production outages and how you fixed them.
  • Benchmarks and performance comparisons of languages/frameworks.

Make yourself at home. Drop a comment, share what you're currently working on (or struggling with), and let's build a community that actually cares about how things run under the hood.

reddit.com
u/No-Job-5616 — 2 months ago
▲ 43 r/Backend

How to split 10GB JSON files in seconds without hitting RAM limits

I had a real problem at work: we were constantly processing massive JSON array dumps — catalogs, analytics exports, ML datasets. The files ranged from hundreds of megabytes to tens of gigabytes.

The task was simple: split a giant JSON array into individual objects so they could be routed, chunked, or processed in parallel. That's it. No transformation, no querying by field name — just find where each {...} starts and ends.

And yet, we were doing json.Unmarshal → slice → json.Marshal. On a 10 GB file, the memory usage was absurd, and we spent more time fighting the GC than doing actual work.

At some point I realized: we don't need to understand the data to move it. We just need to find the boundaries.

The idea: boundary extraction

Instead of building an object tree (which is what every parser does — even fast ones like simdjson or sonic), you can treat JSON as a byte stream with structural markers and just scan for the edges of each object.

The logic boils down to a small state machine:

  1. Nesting counter: { increments, } decrements.
  2. String tracking: you need to know if you're inside "..." so you don't count braces inside string values.
  3. Escape handling: a \" inside a string is not a real quote.
  4. The boundary: when the nesting counter returns to 0 after being >0, you've found one complete object.

That's it. You don't look at keys. You don't look at values. You don't allocate anything. You return slices (memory views) into the original buffer.

Here's the conceptual core in Go (simplified, without the string-tracking):

gofunc findBoundaries(data []byte) []Chunk {
    var chunks []Chunk
    depth := 0
    start := -1
    for i, b := range data {
        switch b {
        case '{':
            if depth == 0 {
                start = i
            }
            depth++
        case '}':
            depth--
            if depth == 0 &amp;&amp; start &gt;= 0 {
                chunks = append(chunks, Chunk{Start: start, End: i + 1})
                start = -1
            }
        }
    }
    return chunks
}

Of course, this naive version is wrong — it doesn't handle strings, escapes, or nested arrays. But the principle is the key insight: you're not parsing, you're scanning.

Why this is fast

When you scan boundaries instead of parsing:

  • Zero allocations in the hot path. You return data[start:end] — a slice of the original buffer. No new objects, no string copies, no map construction.
  • Cache-friendly. Your working state is a couple of integers (depth counter, string flag). Everything fits in L1. The only memory access pattern is a linear sequential read.
  • Pipeline-friendly. A state machine with predictable transitions is much kinder to the CPU's branch predictor than a parser that dispatches on dozens of token types.

Compare this to what a "real" parser does:

Step Full parser Boundary scanner
Read bytes
Classify tokens Only {}[]"\
Build hash maps
Allocate strings
Allocate slices
Type conversion
Return objects []MyStruct [][]byte (slices into original buffer)

You're literally removing 80% of the work.

But how fast, really?

I got curious and decided to implement this properly — with a real string-aware state machine, correct escape handling, and a full AVX2 assembly kernel on amd64 for the hot scanning loop (processing 32 bytes per cycle, using SIMD bitmasks to classify structural characters in parallel).

Honestly, the results surprised even me:

Approach What it does Throughput Memory overhead
encoding/json Unmarshal Full parse → Go structs ~107 MB/s 3-4x input size
sonic / simdjson-go Optimized parse → structs/AST ~400–700 MB/s ~1.1x
Boundary scan (AVX2 asm) Just finds {...} edges ~4.1 GB/s ~1.0x (zero extra)

>

The ~4.1 GB/s number is essentially limited by memory read bandwidth on my machine, not by the scanner's logic. The AVX2 kernel spends most of its time waiting for the next cache line to arrive.

When this is (and isn't) useful

Good fit:

  • Splitting a huge JSON array into files/chunks for parallel processing
  • Streaming: extracting objects from an io.Reader on-the-fly without loading the whole thing into RAM
  • Proxying/routing: forwarding individual JSON objects from an array to different consumers
  • Pre-processing for a real parser: find boundaries first, then parse each chunk on a separate goroutine

Bad fit:

  • You need actual field values (you'll need a parser anyway)
  • Your JSON isn't arrays-of-objects (this technique targets [{...},{...},...])
  • Small files where the overhead of json.Unmarshal is negligible

The tradeoffs

Since I went the unsafe / raw assembly route for maximum performance, there are real costs:

  • Platform-specific. The AVX2 path only works on amd64. You need a scalar fallback for ARM/other architectures.
  • unsafe usage. Any time you avoid allocations by returning slices into a shared buffer, you need to be very careful about the buffer's lifetime. If the underlying []byte gets overwritten or GC'd while you still hold slices — boom.
  • Not a validator. The scanner trusts that the input is valid JSON. It won't give you nice error messages about malformed data.

Closing thought

The mental shift was surprisingly simple: stop thinking "I need to parse this JSON" and start thinking "I need to find the boundaries in this byte stream." Once you reframe the problem, the solution practically writes itself — and the performance gap compared to full parsing is enormous.

Has anyone else gone down this path? I'm curious how people handle large-scale JSON splitting/routing in production — especially the streaming case where you can't load the full file.

reddit.com
u/No-Job-5616 — 2 months ago