# What is in a Kotlin/Native binary, and where its time goes

> One symbol table answers both questions. Every expectation I brought to it was wrong: the runtime is a 40 KB constant, a fifth of the file is symbol names, most of a release binary is not Kotlin, and a sampling profiler written in Kotlin hangs the process it profiles.

Page: https://kotlin.website/blog/what-is-in-a-kotlin-native-binary · Published: 2026-09-12

`bloaty` will tell you that `kfun:io.ktor.server.engine#embeddedServer(...)` is 4,112 bytes. `perf`
will tell you the same mangled symbol was on the stack. Both are true and neither is something you
can act on, because neither knows that a Kotlin package is a thing.

So I wrote [razves](https://github.com/youndie/razves), which reads the symbol table of a
Kotlin/Native binary and aggregates it into packages and klib modules — first for bytes, then, out of
the same table, for time. What follows is what the measurements said, including the six places where
they contradicted the plan I started with.

Everything below is Kotlin 2.4.10, measured on six real binaries — an OIDC provider that links
OpenSSL and a Rust database driver, a conformance client, razves' own two, and a fixture that is as
close to an empty program as a Kotlin/Native executable gets.

## The runtime is a constant, and it is small

**37,889 to 40,871 bytes**, across all six binaries — a spread of under 3 KB over a **43× range of
file size**. Its share of the file falls from 17.6% in the smallest to 0.2% in the largest.

Which means the sentence "the Kotlin/Native runtime is 17.6% of my binary" tells you your binary
does almost nothing. It never tells you the runtime is heavy.

The floor underneath it: **496,232 bytes** for one `println` and one dependency. Of that, **135,032
bytes are the symbol table** — the names, not the code.

## Most of a release binary is not your code, and not Kotlin either

In the largest subject, Kotlin accounts for **36–40% of the attributed bytes**. The majority is
statically linked C: OpenSSL, arriving through `ktor-client-curl`. Where a Rust-backed database
driver is used, about **1.3 MB of `tokio` and `sqlx`** comes with it.

And **2,366,786 bytes of that binary are defined by two different builds of OpenSSL at once** —
which you can only see if you ask which archive defines each symbol, because the C ABI has no
namespaces and no grammar over names can tell you.

## A fifth of the file is names

**19–27% of every one of the six binaries is the symbol table.** It is the largest single removable
thing in a Kotlin/Native executable — and it is exactly what any of this reads. Strip it and the
binary gets a fifth smaller and stops being explicable.

Debug builds are a different scale of the same point. The same program, debug against release:
**5,352,944 bytes down to 496,232, −90.7%**. Nine tenths of a debug binary is debug information:
`.debug_str` 1,860,741 bytes, `.debug_info` 1,145,773, `.debug_line` 1,011,093 — all of it gone in
release, where what remains is the floor.

## The trap that would have made all of it wrong

Rust's legacy mangling starts with `_ZN`. So does Itanium C++.

Group them together — which is the obvious first pass, and which I wrote — and **1,263,149 bytes**
of `tokio` and `sqlx` land in a row labelled "the Kotlin/Native runtime". That is **31 times** what
the runtime actually is in that binary: 40,871 bytes. The report would have named the Kotlin/Native
runtime the third-largest thing in the file, and the number would have been somebody else's code.

A Rust legacy symbol ends in `17h` followed by sixteen hex digits and an `E`. That suffix is the
whole difference between a table you can trust and a table that is confidently wrong.

## The toolchain does not ship a binary reader

The plan was to wrap `llvm-nm`: it comes with Kotlin/Native, so the dependency is free.

It does not come with Kotlin/Native. The LLVM distribution the compiler downloads is the
`…-essentials-` one, and it contains no `llvm-nm`, no `llvm-size`, no `llvm-objdump`, no
`llvm-strip`. A free dependency that is not there is not free.

So razves reads ELF and Mach-O itself, with no subprocess at all. That turned out to matter a second
time: **`nm` reports a size of zero for every Mach-O symbol**, because the format records none. Apple
targets need sizes derived from the distance to the next symbol — an algorithm, not a flag.

## The same table, asked about time

A sampling profiler needs three things razves already had: a symbol table reader, an address-to-symbol
map, and a Kotlin mangling grammar. So the profiler is the same tool asked a different question — and
the in-process half is deliberately tiny: a timer, a signal handler, a fixed ring of stacks. Raw
addresses leave the process; every name is resolved afterwards, out of the binary.

The plan was to write that handler in Kotlin.

## A Kotlin signal handler hangs the process

| handler | rate | runs | completed | crashed | hung |
|---|---|---|---|---|---|
| Kotlin | 100 Hz | 10 | 7 | 0 | **3** |
| Kotlin | 1 kHz | 10 | 1 | 1 | **8** |
| Kotlin, atomic increment only | 1 kHz | 10 | 2 | 2 | **6** |
| Kotlin, non-allocating workload | 100 Hz | 10 | 10 | 0 | 0 |
| C, via cinterop | 100 Hz — 10 kHz | 10 each | 10 | 0 | 0 |

Three readings, and none of them is "it works". The failure rate **rises with the sampling rate**. It
**disappears when the sampled workload stops allocating** — the handler is interrupting the allocator.
And it is not what the handler does: one that only increments an atomic fails just as often, because
what is unsafe is entering the Kotlin runtime from a signal at all.

The same sampler in C completed every run at every rate. That is the whole reason the in-process half
of this thing is forty lines of C behind a `.def` file.

## The rate you ask for is not the rate you get

| timer | requested | delivered |
|---|---|---|
| `setitimer(ITIMER_PROF)`, Linux | 1,000 Hz | **219 Hz** |
| `timer_create(CLOCK_THREAD_CPUTIME_ID)`, Linux | 1,000 Hz | **207 Hz** |
| `timer_create(CLOCK_MONOTONIC)`, Linux | 10,000 Hz | **8,204 Hz** |
| `setitimer(ITIMER_REAL)`, macOS | 1,000 Hz | **964 Hz** |
| `setitimer(ITIMER_PROF)`, macOS | 1,000 Hz | **334 Hz** |

Both CPU-time clocks on Linux saturate at the same ~200 Hz, and the API has nothing to do with it: a
process or thread CPU clock is advanced on the scheduler tick, so asking a tick-bound clock for a
millisecond gets you four. A monotonic timer is driven by a high-resolution one and delivers what it
was asked for.

They are not interchangeable — one samples a thread only while it runs, the other whether it runs or
not — but whichever you choose, a profile that quotes the rate it *requested* has percentages that
mean nothing. Count the ticks.

## What sampling costs: a stand that refused to answer

The first number I wanted was "sampling costs X% of CPU at 100 Hz". I still do not have it, and the
reason is worth more than the figure.

The machine's own run-to-run spread was 24–56% of its median. At 8.2 kHz — eighty times the rate in
question — the sampled runs came out **faster** than the unsampled ones in four pairs out of seven.
Any percentage from that stand would have been noise wearing a suit.

So the stand was rebuilt: both halves of each pair in one process, seconds apart, alternating which
runs first, pinned to one core, and — this is the part that matters — it **measures its own resolving
power** before it reports anything, by doing a known amount of extra work and checking it can see it.
On the build machine it resolves 5%. At about 900 Hz the cost of sampling is under that, so the honest
form is a bound, not a figure.

Then it refused the shortcut, too. Measure where the cost *is* visible and scale down? The delivered
rate rose from **7,216 Hz to 17,944 Hz** and the cost went from **6.59% to 7.22%** — two and a half
times the work for no change worth the name. Whatever dominates it at those rates is not per-sample
work, so no high-rate measurement can be divided down to 100 Hz.

## The profiler was in its own profile

The first end-to-end run reported **100% of the time in one C function** on a workload that is mostly
Kotlin.

`backtrace()` is called from inside the signal handler, so its frame 0 is the handler and frame 1 is
the kernel trampoline. Every sample had the same leaf. Every unit test passed while it did — because
they all handed the aggregation stacks a test had made up.

The fix is not a bigger skip count: the interrupted program counter is sitting in the context the
kernel hands the handler, exact and free. Before and after, same program, same rate:

| | leaf in C | leaf in Kotlin | named |
|---|---|---|---|
| before | 100.0% | 0.0% | 100.0% — all of it the handler |
| after | 44.7% | 49.7% | 99.1% |

If you take one thing from this article, take that one: **a pipeline tested only with synthetic
inputs is green while the collector at its head is broken.**

## The same program on two platforms is two different-looking programs

Profile the identical source on Linux and on an Apple arm64 machine and you get the same package
names in a different order:

| package | linuxX64 | macosArm64 |
|---|---|---|
| `kotlin.collections` | 41.1% | 51.0% |
| `c` | 47.5% | 24.8% |
| outside the binary | **1.4%** | **16.4%** |

macOS links libsystem dynamically, so the allocator frames are outside the image and can only be
reported as such; the Linux build carries the same code statically inside it, where it is named. Same
work, two accounting treatments. A profiler that folded those frames into the nearest Kotlin one
would make the two runs look like two different programs.

## And finally, the tool measuring itself

Adding an MCP server to the razves command took it from **3,315,112 to 6,948,816 bytes** — it more
than doubled. Asked about itself, razves named the reason: `io.modelcontextprotocol.kotlin` at
**1,235,919 bytes across 5,858 symbols**, the largest package in the file, with coroutines and Ktor
behind it.

A tool whose entire argument is "where did your bytes go" cannot ship that in the command people run
to find out. It is a second binary now. That decision took ten minutes and one measurement, which is
the point of having the measurement.

---

razves is [on GitHub](https://github.com/youndie/razves), published as snapshots, and reads any
Kotlin/Native binary you point it at — including the one you are about to be surprised by.
