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, 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, published as snapshots, and reads any Kotlin/Native binary you point it at — including the one you are about to be surprised by.