Inko 0.20.0: reducing heap allocations by 50%

11 min read Original article ↗

Published on

We're pleased to announce the release of Inko 0.20.0. This release includes support for escape analysis to drastically reduce heap allocations, structured logging, atomically reference counted types, better method inlining, and a lot more.

If you're new to Inko: Inko is a programming language for building concurrent software, but without the usual headaches such as data race conditions and non-deterministic garbage collectors. Inko features deterministic automatic memory management, compiles to machine code using LLVM, supports different platforms (Linux, macOS and FreeBSD, and potentially any other Unix based platform), and is easy to get started with. For more information, refer to the homepage or the manual.

While not directly related to this release (but enabled by the code that went into it), we're also pleased to announce that our website and release artifacts are now served by Inko itself (instead of Cloudflare), using shost as the HTTP server (and without a reverse proxy such as Nginx).

Table of contents

This release includes a lot of changes not included in this announcement. For the full list of changes, refer to the changelog.

A special thanks to the following people for contributing changes included in this release:

Reducing heap allocations using escape analysis

In Inko types are defined using the type keyword, for example:

type Person {
  let @name: String
  let @age: Int
}

Instances of such types are allocated on the heap using malloc(). To create a type that is allocated on the stack instead, one must use type inline like so:

type inline Person {
  let @name: String
  let @age: Int
}

Inline types come with some restrictions and caveats that mean you might not always be able to use them. Most notably, inline types only allow assigning fields new values when done using an owned reference and not a (mutable) borrow.

Inko 0.20.0 introduces an implementation of inter-procedural escape analysis performed during the inlining pass. This optimization looks at heap allocated values and determines if they escape their allocated scope or not. If they don't escape the allocation is "promoted" to the stack, removing the need for dynamic memory allocation at runtime for these values.

The implementation is inter-procedural meaning that the compiler looks at the program as a whole, instead of processing methods in isolation. This allows for more fine-grained analysis, such as when dealing with method calls that aren't inlined. For example, if a method call isn't inlined but its arguments are flagged as "won't escape" then heap values passed as the arguments may still be promoted to the stack.

The inliner looks at all method calls in a program and processes them in reverse topological order, using Tarjan's strongly connected components algorithm. This means that if method A calls method B, B is processed before A. Escape analysis runs as part of this traversal so that we don't need to perform it twice, and so that callers can use the escape analysis results of their callees whenever necessary.

Escape analysis has traditionally been applied to more dynamic languages such as Java, and in more recent years Go. The improvements tend to be modest: somewhere between 10% to 20% on average (with an occasional outlier) based on various papers we looked at (and assuming our understanding is correct):

For Inko the implementation is much more effective, with an average of 50% of heap allocated values that are promoted to stack allocated values. This average is based on the escape analysis statistics of the following projects:

  • shost: an HTTP server for hosting static websites
  • OpenFlow: a central ventilation control system
  • kvi: a simple key-value database
  • idoc: Inko's documentation generator
  • wtml: a simple program for processing bank statements from ING

For these projects the percentage of heap values promoted to stack values are as follows:

ProjectPromotedEscaping
shost58%42%
OpenFlow51%49%
kvi50%50%
idoc47%53%
wtml54%46%

These numbers are produced by running inko build --release --escape-stats for each project. Of course these numbers will differ between projects but an average of 50% is a promising start, and there are plenty of ways the inlining and escape analysis passes can be improved upon in the future.

Better inlining of closures

Before 0.20.0 if a method that received a closure argument was inlined then calling the closure (using the call method) would still result in an indirect call, and that call wouldn't be inlined further. For example, this code:

fn foo {
  bar(fn { 10 })
}

fn bar(closure: fn -> Int) {
  closure.call
}

Would be inlined into this:

fn foo {
  let closure = fn { 10 }

  closure.call
}

As part of the work on implementing escape analysis we also improved the inlining of closure calls, and of course closures themselves are also promoted to stack allocated values where possible. The result is that for the above example the call call would be inlined, resulting in essentially something like this:

fn foo {
  let closure = fn { 10 }

  10
}

Future optimizations may be applied to further optimize the closure away entirely whenever possible.

Better handling of recursive methods when inlining

To ensure the method inliner doesn't get stuck in an infinite loop trying to inline recursive methods, the inliner uses Tarjan's strongly connected components algorithm and doesn't inline method calls that are recursive.

This release includes several fixes for cases where the inliner would still get stuck and a fix for a bug that would prevent callees of recursive methods from being inlined into their callers.

Structured logging

This release introduces the std.log module in the standard library for structured logging of wide events, based on the findings of articles such as Logging sucks and Using Canonical Log Lines for Online Visibility. Instead of logging arbitrary text that nobody ever looks at and consumes tons of storage space, you log fewer but wider (= more fields) events. An event is not arbitrary text such as "The user failed to log in" but something you can query/search for with ease, such as user_login_failed or http_request. For example, shost produces logs such as the following (using JSON as its output format):

{
  "time": "2026-04-21T16:39:44.398Z",
  "name": "http_request",
  "fields": {
    "address": "::ffff:XXX.XXX.XXX.XXX",
    "method": "GET",
    "host": "yorickpeterse.com",
    "path": "/feed.xml",
    "version": "1.1",
    "user-agent": "CommaFeed/5.3.4 (https://github.com/Athou/commafeed)",
    "status": 304
  }
}

Getting started with the std.log module is easy:

import std.log (Logger)

type async Main {
  fn async main {
    let logger = Logger.text

    logger.event('example').with('name', 'Alice').with('age', 42).submit
  }
}

This produces the following output:

2026-04-21T16:44:07.557Z example name="Alice" age=42

Formatting log output is done asynchronously such that the cost for log producers remains consistent, regardless of how complex the output format is or where it's sent to (STDOUT, an external service, etc).

For more details refer to the documentation of the std.log module.

Immutable and atomically reference counted types

Inko takes a shared-nothing approach to concurrency: processes are isolated from each other and can't share memory, instead memory is moved between processes. Sometimes you do have to share data, and sometimes using a process to access the data is too expensive.

This is where Inko's new atomically reference counted types come in. Such types are defined using type ref:

type ref Person {
  let @name: String
  let @age: Int
}

Such types use atomic reference counting and are considered value types. This means the following is valid, which wouldn't be the case for regular types:

let a = Person(name: 'Alice', age: 42)
let b = a

a.name # => 'Alice'
b.name # => 'Alice'

These types are immutable, so you can't assign fields new values or mutate them in-place. In addition, they may only store other ref or copy types (e.g. Int).

The standard library introduces two new types that build upon this new feature and use atomic operations:

Both types use acquire and release semantics for their atomic operations, such as for storing a new value. These new types are useful when you need some degree of synchronisation across processes and the cost of sending messages between these processes is deemed too high.

Compressing and decompressing data using gzip

This release introduces the new standard library module std.compress.gzip that adds support for compressing and decompressing gzip streams. For example, compressing data is done as follows:

import std.compress.gzip (Encoder)
import std.stdio (Stdout)

type async Main {
  fn async main {
    let enc = Encoder.new(Stdout.new)

    enc.write('hello world').or_panic
    enc.finish.or_panic
  }
}

In addition, the method std.net.http.server.compress_response allows for compressing of HTTP responses.

The implementation of gzip is built on top of the libz-rs-sys crate from the zlib-rs project, a pure Rust implementation of the zlib API. This crate is included in Inko's runtime library such that no extra C dependencies are required.

Easier generating of JSON

This release includes a new set of methods and types for generating JSON:

For example, instead of writing this:

import std.json (Json)

type async Main {
  fn async main {
    let map = Map.new

    map.set('name', Json.String('Alice'))
    map.set('age', Json.Int(42))

    Json.Object(map).to_string
  }
}

You can now write this instead:

import std.json (Json)

type async Main {
  fn async main {
    Json.object.string('name', 'Alice').int('age', 42).into_string
  }
}

Portable API for interrupting accepting of connections

This release introduces a new API for interrupting calls to TcpServer.accept. In Inko 0.19.1 this was achieved by cloning a TcpServer, sending it to another process and then calling shutdown on the socket. Unfortunately this doesn't work on macOS, meaning it was impossible to interrupt such calls, such as when an HTTP server should shut down in response to a signal.

Using the new API involves three steps:

  1. Create a TcpServer using TcpServer.new
  2. Get a Notifier using TcpServer.notifier
  3. Call Notifier.notify to interrupt the corresponding TcpServer

Internally the new API uses a pipe for sending and receiving the notification, and the system's polling mechanism (e.g. epoll) to wait for either a new connection or a notification. Most important of all, this new API works across all platforms supported by Inko.

Improved memory usage for String

The String type's layout used to be the following:

╭──────────────────────────╮
│ Object header   16 bytes │
├──────────────────────────┤
│ Size             8 bytes │
├──────────────────────────┤       ╭────────────────────────╮
│ Bytes pointer    8 bytes │ ────> │ String value   N bytes │
╰──────────────────────────╯       ╰────────────────────────╯

This meant that allocating a String required at least 32 bytes of space and two allocations: one for its contents, and one for the container.

Inko 0.20.0 instead uses the following layout:

╭───────────────────────────╮
│ Size              8 bytes │
├───────────────────────────┤
│ References        4 bytes │
├───────────────────────────┤
│ heap/stack flag   1 byte  │
├───────────────────────────┤
│ String value      N bytes │
╰───────────────────────────╯

That is, there's only a single value that needs to be allocated and it embeds both the meta data such as the size and the string contents. This means a String only needs a single allocation and its minimum size is reduced from 32 bytes to 16 bytes.

Improved performance for parsing HTTP headers and methods

This release includes performance optimizations for std.net.http for parsing HTTP headers and HTTP methods, improving performance by 2.2x and 2x respectively.

Improved process scheduler performance

This release includes various performance improvements for the process scheduler, such as:

The need for these improvements arose after migrating the Inko website from Cloudflare to Hetzner and using shost (written in Inko) to host the website, as doing so revealed various performance bottlenecks in the scheduler.

Fixed static linking when using Zig as the linker

Thanks to Jonathan Hult, static linking when using Zig as the linker now works as Zig doesn't support the -l:libX.a we used for statically linking C libraries.

When generating new projects, the inko- prefix is removed

Thanks to Tomoki Aonuma, generating a project using inko init now strips the inko- prefix (if present) from the name. This way running inko init inko-foo results in a foo/ directory containing the project, instead of it creating an inko-foo/ directory.

Getting the sign of an Int

Thanks to John R. Durand it's now possible to get the sign of an Int using Int.sign.

Rust 1.85 is now required

Starting with Inko 0.20.0, compiling the compiler and runtime library requires Rust 1.85 or newer. Rust 1.85 was released in February 2025 and is widely available across many platforms, so this shouldn't pose a problem.

UIntN types are now called UintN

For FFI purposes various C unsigned integer types are available. These types used the format UIntN where N is the number of bits: UInt8, UInt32, etc.

In Inko 0.20.0 these types are renamed to use the format UintN, so Uint8 and Uint32 for example.

Following and supporting Inko

If Inko sounds like an interesting language, consider joining the Discord server or star the project on GitHub. You can also subscribe to the /r/inko subreddit.

Development of Inko is self-funded, but this isn't sustainable. If you'd like to support the development of Inko and can spare $5/month, please become a GitHub sponsor as this allows us to continue working on Inko full-time.