Inko 0.13.2 released
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
- Scheduling using time slices instead of reductions
- Applying LLVM optimizations
- Improvements to maintaining unit tests
- Recovering values when assigning process fields
- Constraining generics to owned types
- Standard library improvements
- Type checking and type inference fixes
- Following and supporting Inko
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:
Iter.skip
Iter.skip_while
andIter.take_while
Iter.take
Iter.try_each
andIter.try_reduce
Bool.then
Array.index_of
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.