systems notes - Go, GPUs, and pipelines that don't allocate

ClassMesh v1: classifying a million log lines for the price of electricity

2026-07-09 · code on GitHub · Go, single binary, MIT

ClassMesh is a classification cascade I've been building in Go. The short version: a stream of records goes in, each record exits at the cheapest stage that can decide it with enough confidence, and only what's left over gets passed to anything expensive. The first tagged release is out, so this seemed like a good time to write up how it works and what it does and doesn't do yet.

Why I built it

I work on data pipelines, so a lot of what I deal with is high volume streams of records that need to be sorted into buckets. Logs, events, rows. There are two standard ways to do this and both of them annoy me for different reasons.

The first is running a model on every record. The problem is cost. Back of the envelope: a million short log lines through a budget LLM API, at around 25 input and 5 output tokens per line, comes out to about $6.75 per million lines, and you're paying API latency on top. For a pipeline that sees millions of lines an hour that adds up fast, and most of those lines are health checks and other noise that a string contains check could have handled anyway.

The second is the hand rolled regex pipeline. I've written a few of these over the years and they always end up in the same place: a file of rules nobody fully remembers the reasons for, no notion of confidence, and no story for what happens to records that nothing matched. It works, but it's not something you enjoy maintaining.

What I wanted was the middle option. Run the cheap deterministic checks first, let everything they can confidently label exit right there, and only escalate the leftovers to slower and more expensive stages. Each stage has a confidence gate. Records that clear it exit, records that don't get passed down, and anything that survives every stage goes to a review sink rather than getting silently dropped. The expensive stage can still exist, it just sees a small fraction of the traffic.

How it works

Everything in ClassMesh is an interface. Sources produce records, stages classify them, sinks receive them. Right now the sources are text files, stdin, and JSONL, the stages are rules, schema checks, and a mock model stand-in (more on that below), and the sinks write JSONL. Anything that implements Source can be a source, so Kafka or whatever else is possible later without touching the engine. The core is payload agnostic on purpose. Logs are just the first use case.

Here is more or less what it looks like:

log / event stream records stage 1: rules 46 ns/record, confidence 1.0 decided undecided stage 2: model deterministic mock in v1 decided still undecided review sink classified JSONL category + confidence + reason
every record exits at the cheapest stage that can decide it

A whole cascade is declared in one YAML file:

version: 1
input:  { type: text }
stages:
  - { id: rules,  type: rules,  path: rules.yml, gate: 1.0 }
  - { id: schema, type: schema, path: schema.yml }
sink:   { type: jsonl, stream: stdout }
review: { type: jsonl, path: review.jsonl }

There's a classmesh validate command that checks the config before any input gets opened. Unknown keys, duplicate stage ids, and out of range gates all get rejected up front, which has saved me from myself a few times already. You can also declare category routes, so classified records get dispatched to different sinks by category. Setting workers: N runs classification on N goroutines while keeping output order and stats the same as the serial version. The default is serial.

Each classified record comes out as a JSON object with the category, the confidence, which stage decided it, and the matched rule's reason string. The reason part was important to me because the rot in regex pipelines is mostly about legibility. If a rule fires six months from now, the output should be able to tell you which rule and why.

Concretely. Here's a rule from the example ruleset:

rules:
  - id: payment-failure
    category: billing
    regex: ["payment (failed|declined)"]

This line comes in:

payment declined for order 7

And exits stage 1 as:

{
  "id": "examples/logs.txt:2",
  "data": "payment declined for order 7",
  "category": "billing",
  "confidence": 1,
  "stage": "rules",
  "reasons": [{"code": "payment-failure", "detail": "regex \"payment (failed|declined)\""}]
}

(Formatted for reading; the real output is one object per line.) The same ruleset classifies structured JSON events on their decoded fields: an any/all block can say things like field http.status gte 500, and the reason string in the output reads all(field level == "error" and field http.status >= 500). Six months from now that line still tells you which rule fired and what it checked.

The numbers

Measured on a single core of my Ryzen 7 3800X, re-run for this post. The benchmarks are in the repo and run with make bench, so you don't have to take my word for any of this:

PathPer recordThroughputAllocs
Rules stage, first-rule hit46 ns~22M rec/s0
Rules stage, 20-rule regex-heavy miss (benchmark ruleset)7-8 µs~130k rec/s0
Full pipeline (engine + rules, discard sink)500-550 ns~1.8M rec/s0
Integrated (text source -> rules -> JSONL sink)~600 ns~1.6M rec/s2

The allocations column is the one I spent the most time on. The hot path doesn't allocate at all, which is what keeps throughput stable when the stream gets ugly, since there's no GC pressure building up in the background. Getting there took a while. The miss path against literal-bearing patterns, where a record walks the rule list and matches nothing, started around 7 microseconds and ended up around 1 thanks to a compile-time literal prefilter: a pattern with a literal prefix hides behind a cheap contains check, so the regex engine only runs on lines that could possibly match. A genuinely regex-heavy walk still costs the ~6-7 microseconds in the table. Concurrent batch classification went from 7.6ms to about 3.0ms for 100k records across 16 workers. Nothing exotic, mostly restructuring things so values stay on the stack and the regex engine gets asked less often.

One practical note: per record cost depends entirely on your ruleset, so order rules by expected volume. The first-rule 46ns number assumes the common case actually hits early.

Try it in 60 seconds

git clone https://github.com/ClassMesh/classmesh
cd classmesh
go run ./services/cli/cmd/classmesh run \
  --rules examples/rules.yml examples/logs.txt

That runs the example ruleset over the example logs and prints one JSON object per classified record, with undecided records counted on stderr. JSONL events work the same way with --input jsonl, classified on their decoded fields. The only dependencies beyond the standard library are two small pure-Go ones: yaml.v3 for config parsing and x/text for tokenizer normalization.

The bundled sample is four lines, which proves the mechanism, not the throughput. For volume there's a generator in the repo that writes a deterministic weighted stream: 70% health-check noise, 15% billing, 10% auth, 5% that nothing matches, which is roughly what real traffic looks like:

go run examples/genlogs.go -n 1000000 > logs-1m.txt
go run ./services/cli/cmd/classmesh run \
  --rules examples/rules.yml logs-1m.txt > /dev/null

On my machine that reports processed=1000000 classified=950243 review=49757 on stderr in about 0.7 seconds, JSON output included. Same seed, same stream, so your counts should match exactly; only the wall time is yours.

The repo also ships the full cascade as a runnable demo, not just the rules stage: a second generator, a config that declares both tiers, and four commands from a fresh clone:

go run examples/genprod.go -n 1000000 > prod.log
make build
./bin/classmesh validate --config examples/classmesh.yaml
./bin/classmesh run --config examples/classmesh.yaml prod.log > classified.jsonl

genprod writes a million production-shaped lines: access logs, health probes, payments, auth events, warns, errors, business chatter. Deterministic by seed, like the first generator. The rules tier decides 88% of the stream, the mock model tier behind its 0.7 gate takes another 6%, and the last 6% lands in review.jsonl. Probe noise gets classified and then dropped by a route, so stderr reads processed=1000000 classified=940162 review=59838 while classified.jsonl itself holds 720,490 records. The whole run takes about 1.7 seconds on my machine. Same seed, same numbers.

Or try a line right here

The demo above needs a clone and a build. If you just want to poke at the cascade, the same two-tier ruleset from examples/ is re-implemented below. To be clear about what this is: a faithful re-implementation of the example ruleset running in this page, not the real binary; the repo runs the real thing. Same semantics though: tiers in order, first match wins, matches are case-sensitive. Type a line, or throw one of the demo's own lines at it.

the demo's eight lines

the rules this playground implements, in match order

tier 1 - rules - every match 1.0

  1. health-noise: contains "/healthz" / "/readiness" -> noise
  2. access-ok: contains "status=200" / "status=201" / "status=204" -> traffic
  3. app-ops: contains "cache miss" / "job completed" / "connection pool" -> ops
  4. payment-event: regex payment (succeeded|declined|failed) -> billing
  5. auth-signal: contains "unauthorized" / "login failed" / "rate limited" -> auth
  6. alert: regex status=[45][0-9][0-9], or contains "error " / "panic" / "OOMKilled" -> alert

tier 2 - mock - gate 0.7

  1. contains "warn " -> anomaly, 0.85: clears the gate
  2. contains "shipment " / "inventory " -> logistics, 0.60: fails the gate
  3. anything else -> unknown, 0.40: fails the gate

What's working well

The single binary thing has been as convenient as I hoped. There's nothing to install next to it and nothing to configure beyond the YAML. The interface design also made testing much easier than I expected; there are in-memory implementations of source, stage, and sink for tests, and the benchmarks live next to the code they measure, so the table above is a make target rather than a screenshot from some run I can't reproduce.

The cost model works out the way the design intended. The rules stage chews through the same million lines that would cost $6.75 via an API in under a second per core, output included, and the generator above is the receipt. Only what rules can't decide moves on to anything that costs money.

The bad

The model stage isn't real yet. v1 is rules, schema checks, the cascade config, category routing, and the parallel engine. The model tier is currently a deterministic mock that scores records with confidences below 1.0. It exists so the gating and review routing could be built and tested against something, and I want to be upfront that it's a stand-in. Some of the groundwork for the real thing is already in the repo, including a wordpiece tokenizer, but the actual model stage is v2.

Sources are files and stdin for now, so anything else means implementing Source yourself. And to be clear about what this is: it's a classification engine you point at a stream, not an observability platform. If you need dashboards, this feeds them, it doesn't draw them.

What's next

v2 is the real model stage. Further out, I've been working on running small models on the GPU from the same static Go binary, no Python and no CUDA toolkit, through a pure Go CUDA driver binding called gocudrv. That one is getting its own post.


Code: github.com/ClassMesh/classmesh. Issues and benchmark reproductions welcome. If the numbers don't hold on your hardware I'd genuinely like to know.