Inko 0.13.2 released

Published on

We're pleased to announce the release of Inko 0.13.2. This release is a smaller release that focuses on fixing various bugs, extending the standard library, and improving performance.

Table of contents

For the full list of changes, refer to the changelog.

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

We'd also like to thank the following people for financially supporting the development of Inko:

Scheduling using time slices instead of reductions

Before 0.13.2, Inko's scheduler used "reductions" to prevent processes from running on an OS thread indefinitely. The implementation was simple: each process starts with a "reduction" counter set to N, reduces it for certain operations (e.g. method calls), and yields back to the scheduler when the counter reaches zero. The next time the process is scheduled the counter is reset, and the work repeats itself.

While this implementation is simple, it's also costly: the reductions were applied by calling a function provided by the runtime library and as such can't be inlined. This is done for every method call, resulting in a significant overhead. Even if the function were to be inlined, the constant mutation of a counter would likely result in caches being evicted non-stop, resulting in poor performance.

Inko 0.13.2 introduces a new mechanism that reduces the scheduler overhead by up to 40% for applications that call many methods. The way this new setup works is as follows:

A global counter is atomically incremented by a separate thread every 10 milliseconds. When a process is about to run, it obtains the current value of this counter and stores it in itself. The compiler in turn inserts code at various places, known as a "preemption point". This code checks the process-local counter against the global counter, yielding control back to the scheduler if the two values differ. Using pseudo code, this looks as follows:

if process.epoch != atomic-load(global_epoch) {
  yield
}

Atomic operations are performed using the "relaxed" ordering, meaning the overhead is minimal.

Preemption points are not inserted after every method call, instead they are inserted at the end of loop iterations and before next and break expressions. In addition, the code is generated by the compiler instead of relying on function calls.

Processes of course still yield when performing asynchronous IO operations (when needed), and threads blocked on blocking (file) IO are still replaced with backup threads. This combination means the new setup is good enough to prevent Inko processes from claiming an OS thread for too long, at a fraction of the cost compared to the previous approach.

Refer to commit cc8c6fe for more details.

Applying LLVM optimizations

Inko by default doesn't apply LLVM optimizations as we're still trying to figure out which optimization passes are worth enabling. In addition, we're still toying with the idea of replacing LLVM with Cranelift at some point in the future.

Unfortunately, this means performance can leave a lot to be desired. To work around this for the time being, inko build --opt=aggressive now applies the optimizations similar to those provided by -O3 when using clang. Depending on the application, this can improve performance at the cost of greatly increasing compilation times.

Improvements to maintaining unit tests

Maintaining unit tests is made easier as Inko now automatically generates the test runner (./test/main.inko) when running inko test. This means you no longer need to maintain this file and import new test files manually.

Refer to commit da6ea1c for more details. Thanks to Bartek Iwańczuk for implementing this!

Recovering values when assigning process fields

Processes are defined using the class async syntax:

class async Foo[T] {
  let @value: T
}

When creating an instance of such a process, the values assigned to the fields must be safe to be moved between process (known as "sendable" values). For example, the following isn't valid:

let nums = [10]

Foo { @value = nums }

The reason this isn't valid is because the compiler doesn't know if there are any references to nums when it's assigned to the value field. To make this correct, we'd have to use the recover keyword, ensuring no outside references to it exist (see the documentation for more details) and turning the type Array[Int] into uni Array[Int]:

let nums = recover [10]

Foo { @value = nums } # => Foo[uni Array[Int]]

This can create a problem: if the field type is generic, assigning it a uni T value results in the generic type being inferred as an uni type, even if you want it to be inferred as an owned value. To solve this, Inko 0.13.2 allows you to use recover to turn an uni T into a T as part of the assignment like so:

let nums = recover [10]

Foo { @value = recover nums } # => Foo[Array[Int]]

This is only allowed if the value of the assignment is the recover expression, otherwise the compiler isn't able to guarantee no outside references to the value exist at the time of assignment.

For more details, refer to commit 1b61961 and issue #641.

Constraining generics to owned types

This release introduces the move annotation for generic type parameters. Using this keyword you can specify that a generic type parameter only accepts or returns owned values. To illustrate this, consider this method:

fn foo[T](value: T) -> T {
  value
}

This method is generic and accepts values of any type and ownership:

foo([10, 20])     # => Array[Int]
foo(ref [10, 20]) # => ref Array[Int]
foo(mut [10, 20]) # => ref Array[Int]

Being generic over ownership does come with a restriction: if we don't know the ownership of the value, we can't move out of the value using an fn move method. Consider the following example:

trait Example {
  fn move example
}

fn foo[T: Example](value: T) {
  value.example
}

If we were to pass foo a reference (e.g. a ref T), we'd be able to move out of the T and deallocate it prematurely.

To solve this, Inko now requires the move annotation for generic type parameters to allow the use of moving methods. This means we have to change our foo method to the following:

fn foo[T: Example](value: move T) {
  value.example
}

This is sound because by using move T we restrict the values to owned values, which we can move out of.

When used in a return type, move T transforms the type assigned to T into an owned value:

fn foo[T](value: T) -> move T {
  ...
}

This method would take a value of any ownership, and somehow convert it into an owned value.

For more information, refer to commit c9c01ac

Standard library improvements

The following methods have been added to the standard library:

Type checking and type inference fixes

This release includes a variety of fixes related to type checking and type inference, resolving the issues #588, #603, #620, #636, #642, #666.

Following and supporting Inko

If Inko sounds like an interesting language, consider joining the Discord channel. You can also follow along on the /r/inko subreddit. If you'd like to support the continued development of Inko, please consider donating using GitHub Sponsors.