The protobuf runtime for code generated by protoc-gen-clojure: records in, protoc's bytes out. Editions supported through 2024.
;; deps.edn
com.github.bpalermo/clj-protobuf {:mvn/version "0.3.1"}
protoc-gen-clojure emits a defrecord per message plus X->proto /
proto->X conversion fns; this library is everything those fns call — the
descriptor machinery, the field codec, and byte-level encode/decode:
(require '[clj-protobuf.core :as pb]
'[acme.greeter.greeter :as g]) ; generated
(-> (g/map->HelloRequest {:name "world" :repeat-count 2})
g/HelloRequest->proto
pb/encode) ; -> bytes, byte-identical to protoc's
(->> bytes
(pb/decode g/HelloRequest-prototype)
g/proto->HelloRequest) ; -> record; absent fields are nil
Records and plain maps are interchangeable everywhere a message value goes.
camelCaseField → :camel-case-field); STYLE_LEGACY files work because
nothing ever converts backwards.:COLOR_RED), lossless
in both directions; numbers, strings and EnumValueDescriptors are accepted
on the way in.FileDescriptorProto; protobuf-java resolves presence, DELIMITED encoding
and utf8 validation when the descriptor builds. This library carries no
edition-specific code, which is why new editions need a protobuf-java bump
and nothing else.Messages but are necessarily dropped
by a record round trip; pb/unknown-fields inspects them when it matters.Generated code carries a Java-class hint per message. When the matching
java_proto_library classes are on the classpath the prototypes silently
switch to the generated classes — protoc's own serializer. Without them,
since 0.2.0, the prototype is this library's own compiled codec: the
descriptor compiled once into reader and writer tables over a slot array,
with none of DynamicMessage's per-call reflection behind it. Every arm
produces byte-for-byte identical output (the byte-identity and equivalence
suites prove all of them against protoc's own Java backend), and
-Dclj-protobuf.codec=dynamic brings DynamicMessage back if you ever need
the reference implementation.
Measured with bazel run //bench:run -- quick (criterium; JDK 21, Linux
x86_64; mean latency / allocated bytes per op; full Clojure-data-to-bytes
pipelines). java is protoc's generated builders driven directly — and, on
decode, parsing only, since protoc's classes are not a Clojure-data path;
hinted is clj-protobuf with the generated classes on the classpath;
interop is protoc-gen-clojure's interop=true output, the same prototypes
as hinted driven through direct Java accessors instead of the codec;
compiled is clj-protobuf without the generated classes; jsonista and
data.json carry the same value as JSON.
This repository builds its own library with Clojure's direct linking on, so these numbers include it — 5-18% depending on shape, and the lever described below rather than a separate one, so do not count it twice. A consumer running the same code without it is a few percent slower on every clj-protobuf row:
| shape | java | hinted | interop | compiled | jsonista | data.json |
|---|---|---|---|---|---|---|
| tiny | 67 ns / 96 B | 148 ns / 136 B | 88 ns / 136 B | 250 ns / 192 B | 375 ns / 576 B | 922 ns / 624 B |
| flat | 620 ns / 400 B | 918 ns / 488 B | 588 ns / 400 B | 1.16 µs / 440 B | 1.11 µs / 1248 B | 3.36 µs / 2208 B |
| deep | — | 802 ns / 552 B | 386 ns / 368 B | 819 ns / 560 B | 770 ns / 1048 B | 2.42 µs / 1392 B |
| wide-repeated | — | 3.27 µs / 3088 B | 3.41 µs / 3088 B | 3.07 µs / 2088 B | 2.41 µs / 1112 B | 6.89 µs / 4216 B |
| repeated-messages | 2.06 µs / 2312 B | 4.60 µs / 2840 B | 4.50 µs / 2840 B | 5.56 µs / 3000 B | 3.95 µs / 4040 B | 18.32 µs / 10424 B |
| map-heavy | — | 8.06 µs / 7512 B | 8.66 µs / 7512 B | 6.29 µs / 4736 B | 4.99 µs / 3632 B | 13.16 µs / 10720 B |
| enum-heavy | — | 1.20 µs / 480 B | 1.20 µs / 480 B | 1.08 µs / 336 B | 1.40 µs / 1184 B | 4.48 µs / 2952 B |
| realistic | — | 3.73 µs / 3672 B | 3.32 µs / 3576 B | 3.86 µs / 3528 B | 4.82 µs / 4040 B | 12.66 µs / 8904 B |
| dense | — | 10.38 µs / 7760 B | 10.03 µs / 7664 B | 13.11 µs / 7496 B | 12.57 µs / 11272 B | 40.08 µs / 24880 B |
| shape | java | hinted | interop | compiled | jsonista | data.json |
|---|---|---|---|---|---|---|
| tiny | 81 ns / 192 B | 159 ns / 232 B | 95 ns / 232 B | 165 ns / 216 B | 673 ns / 1136 B | 768 ns / 1584 B |
| flat | 372 ns / 432 B | 736 ns / 600 B | 439 ns / 600 B | 597 ns / 520 B | 2.17 µs / 2208 B | 3.56 µs / 5816 B |
| deep | — | 756 ns / 1024 B | 459 ns / 1024 B | 681 ns / 760 B | 1.19 µs / 2048 B | 1.18 µs / 3840 B |
| wide-repeated | — | 3.40 µs / 4328 B | 3.36 µs / 4328 B | 3.26 µs / 4208 B | 3.41 µs / 4144 B | 3.34 µs / 11496 B |
| repeated-messages | 1.72 µs / 3328 B | 4.99 µs / 5360 B | 2.92 µs / 5352 B | 5.08 µs / 4704 B | 11.10 µs / 10120 B | 11.71 µs / 25096 B |
| map-heavy | — | 11.78 µs / 13088 B | 11.72 µs / 13072 B | 11.06 µs / 10072 B | 8.46 µs / 5776 B | 13.77 µs / 22288 B |
| enum-heavy | — | 2.07 µs / 1056 B | 1.49 µs / 1056 B | 1.86 µs / 856 B | 2.15 µs / 2880 B | 2.43 µs / 7240 B |
| realistic | — | 3.24 µs / 4280 B | 1.96 µs / 4272 B | 2.72 µs / 3664 B | 7.65 µs / 6872 B | 11.57 µs / 18768 B |
| dense | — | 10.97 µs / 11648 B | 6.83 µs / 11640 B | 9.81 µs / 10144 B | 22.17 µs / 19760 B | 34.15 µs / 57016 B |
The last two rows are one message at production size carried two ways, and
they are the only rows here that separate the two things that drive cost.
realistic is 1025 bytes across 30 leaf values with its bulk in a single
string; dense is 1030 bytes across 120, spread over many small line items.
Almost the same wire size, four times the fields — and on the compiled arm
decode goes from 2.72 µs to 9.81 µs, a factor of 3.6 against a field ratio of
4. Cost tracks field count, not bytes. A large value is close to free per
byte; every field is not. Size a message by counting its fields.
quick trades accuracy for time, and the sub-100-ns cells move between runs
of it — the java column on tiny decode has come out anywhere from 65 to
99 ns. Under the full bazel run //bench:run, Tiny/parseFrom measured
76.7 ns and 66.8 ns in one JVM — the same call, twice — with interop's whole
decode pipeline at 71.4 ns against it and the hinted arm at 119.9 ns. Read
the small shapes as "interop reaches protoc's parse floor", not as a
ranking between the two.
One more lever, measured on a real gRPC service rather than here: this jar
ships as source, so Clojure compiles these namespaces when they load, and
-Dclojure.compiler.direct-linking=true on the JVM turns every call between
them into a static call. On a 1-CPU gRPC server echoing 1 KB messages that
was worth 5–17% of CPU per request depending on shape and rate, and removed a
load-shedding cliff at the top of the ramp. The property is process-wide and
changes late binding for everything loaded from source — with-redefs on a
linked call site stops taking effect — so turn it on deliberately, in
production images rather than at the REPL.
A Bazel consumer has a second route: from rules_clj 0.2.5 a source-only dependency's namespaces can be compiled in the consumer's own build, which is what lets a direct-linked target call into a library published as source. That is rules_clj's mechanism rather than this library's, and its docs are the place to read it; the jar published here stays source either way, so that generated records and call sites specialize against the Clojure and protobuf-java versions the consumer actually builds with.
Read it honestly. Against protoc's own generated code the hinted arm is
roughly 2× on tiny messages and closer on wider ones; the compiled arm is
within 1.7× of the hinted arm at worst and beats it on most decode shapes,
because a slot read is cheaper than a typed-accessor call. interop=true
beats the hinted arm everywhere except the two collection-heavy decode rows,
where they are a wash — building the Clojure collection is the row, not the
accessor — and it allocates exactly what the hinted arm does, since both
produce the same Clojure values from the same generated classes. Against JSON, and this is the
comparison the production rows change: at ~1 KB protobuf wins both
directions and decode by a wide margin — 2.72 µs against jackson's 7.65 on
realistic, 9.81 against 22.17 on dense. On the smaller archetypes it is
narrower: protobuf wins both directions on the small and nested shapes and
wins decode on lists of messages, while jackson wins encoding every
collection-heavy shape and also wins map-heavy decode — building a 50-entry Clojure map is
most of that row, and protobuf pays for entry messages on top. Before 0.2.0
the arm without generated classes was DynamicMessage, two to three times
slower than the compiled one on decode. Wire compactness and schema are
protobuf's argument regardless, and the shapes are archetypes precisely
because no single number describes "protobuf vs JSON".
The interop column needs protoc's Java classes at load time — that is the
interop=true contract — and it is emitted code, not a runtime switch: it
comes from protoc-gen-clojure 0.6.0 or later, which emits direct accessor
calls in both directions (writes were typed earlier). //test:interop_test
holds it to the same bytes and the same values as the codec path, and
//bench:smoke_test holds every arm in these tables to byte-identical
output.
Do not read the interop column as a CPU win, though — this is the clearest
case in these tables of a microbenchmark not surviving contact with a whole
request path. Measured on a real gRPC service rather than here, with
everything else held constant, interop=true returns 15–45% lower p50
because the conversion work moves out of the codec and into protoc's
generated accessors almost one for one, so the total barely changes and only
its distribution does.
Its effect on CPU is the part worth reading carefully, because the honest
answer is that it depends on core count and the published comparison was
partly measuring a defect of ours. On a 1-CPU pod interop cost 3–8% more CPU
per request. On two cores the sign flipped and it cost 10–14% less — on
unchanged images, which is hard to explain by anything except contention,
since contention cannot exist on one core. That contention was this
library's, not protoc's: until 0.2.5 the compiled arm reached a process-wide
monitor on every message built (see //bench:contention).
That re-measurement has since run, and it is worth reporting as it came out rather than as it was predicted. On 0.2.5 the monitor is confirmed gone from the profile — the frame is absent where it was 0.85% of CPU — and the compiled arm's CPU per message fell about 3%, narrowing interop's lead from roughly 10% to 7.5%. The prediction was that it would close to the 1–4% the single-core runs showed. It did not.
The residual is still unexplained, and the reason has since turned out to be that the measurement was bound by something outside the process entirely: the host was saturated. The node ran 4.11 of its 4 cores while the pod sat inside a 2-core quota that was never throttled, because kernel softirq and overlay networking are charged to the node rather than to the pod's cgroup — so no pod-level counter in that harness could see the wall either arm was against. A thread-scaling win has little room to appear on a machine with nothing left to schedule, which makes the 3% a floor rather than an estimate of what the fix is worth.
So: interop's latency advantage is solid, its CPU advantage on multi-core is real but smaller than the pre-0.2.5 numbers said and not fully accounted for. If you are choosing on a multi-core pod, measure your own shape rather than trusting any of these numbers.
Every number above is single-threaded, and that is worth saying because it
is a question these tables cannot answer. Both arms scale close to linearly
across threads — bazel run //bench:contention measures it, with the hinted
arm as a control, because until 0.2.5 the compiled arm did not: two
process-wide synchronized caches on the per-message path capped its encode at
one thread's throughput no matter how many you gave it.
Bazel (with rules_clj) is the build
and test harness: bazel test //.... Plain clj works too: clojure -X:test.
The Clojars artifact is bazel build //src:clojars — jar and pom, from the same
deps.edn and version.edn everything else here reads. bazel run //src:clojars.publish -- --dry-run prints every upload it would make.
Apache-2.0
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |