Nim version 2.4.0 released

The Nim Team is happy to announce version 2.4.0, our new stable release.

After a way-too-long period of development, Nim 2.4 has finally become a reality. This is a major update shipping major new features:

  1. --strings:sso for improved string processing efficiency.
  2. --ic:on for parallel, incremental rebuilds.
  3. --mm:yrc, a new cycle collection algorithm that supports cycles spanning multiple threads.
  4. More convenient sum types.
  5. Cyclic module dependencies: still not encouraged, but fair game once you declare them with {.cyclic.}.

If you’re still on Nim 1.6, take a look at the version 2.0 release article to see all the features you’re missing. If you’ve been using Nim 2.0, the version 2.2 release article shows the improvements available in Nim 2.2. If you want to stay on Nim 2.2 for now, we also released version 2.2.14 today, a patch release with many bugfixes and security fixes.

Installing Nim 2.4.0

Check out if the package manager of your OS already ships version 2.4.0 or install it as described here.

If you have installed a previous version of Nim using choosenim, getting Nim 2.4.0 is as easy as:

$ choosenim update self
$ choosenim update stable

NOTE: We recommend that you install and use the latest version of choosenim, v0.8.16, available in our choosenim repo.

Alternatively, you can download Nim 2.4.0 from our nightlies builds.

Release highlights

Strings based on the small-string optimization (SSO)

SSO-based strings come with a new low-level strings API that you have to use in order to use this switch effectively.

Nim’s new strings API is compatible with Nimony’s, so porting is always beneficial, even if --strings:sso might not improve the performance of your code. See it as a worthwhile, future-proof refactoring in its own right.

The general idea behind this API is to deprecate the operation addr myString[offset] and to replace it with operations that clarify the intent. Reading is distinguished from writing.

Here is our porting guide:

Purpose Use Notes
Read-only raw pointer readRawData(s, start = 0) valid while s is alive & untouched
Raw pointer that survives moves/copies readRawDataStable(s, start = 0) takes var string; promotes small strings to heap first
Bulk write into a string beginStore(s, newLen, start = 0) … endStore(s) sets length, ensures unique ownership, returns writable ptr

Rules of thumb:

  • Reading bytes (memcpy out of a string, memmem, scanning): replace addr s[i] / unsafeAddr s[i] with readRawData(s, i).
  • Writing bytes in bulk (memcpy/fread into a string): replace setLen + addr s[oldLen] with beginStore(s, newLen, start) followed by endStore(s). beginStore already sets the length and does the copy-on-write / unique-ownership dance, so an explicit prepareMutation is no longer needed.
  • A pointer that must outlive the current scope (e.g. a cursor stored next to its buffer): use readRawDataStable, which forces the string onto the heap so the payload address is stable.
  • Single-element write s[i] = c still works unchanged. The compiler lowers it to nimStrPutV3, which handles COW internally. You no longer need prepareMutation(s) before an indexed assignment.

Before / after examples

Bulk copy into a string:

# before
result.setLen newLen
copyMem(addr result[oldLen], buf, buflen)

# after
copyMem(beginStore(result, newLen, oldLen), buf, buflen)
endStore(result)

Reading bytes out of a string:

# before
copyMem(dest, addr(s.data[s.pos]), n)

# after
copyMem(dest, readRawData(s.data, s.pos), n)

Read into a slice of a string:

# before
prepareMutation(buffer)                       # COW literal guard under ARC
result = s.readData(addr buffer[slice.a], count)

# after
result = s.readData(beginStore(buffer, slice.b + 1 - slice.a, slice.a), count)
endStore(buffer)

cstring conversions

  • Converting string → cstring still works, but the C string is only valid as long as the source string is alive and unmodified.
  • If you need a stable, NUL-terminated buffer to hand to C, keep the owning string alive and derive the pointer from it (via readRawDataStable / the normal cstring conversion) rather than caching the pointer past a move.

Migration checklist

  1. Grep your code for addr s[, unsafeAddr s[, addr(...data[, and any cached pointer/cstring into a string.
  2. Replace read accesses with readRawData (or readRawDataStable if the pointer escapes the current scope / must survive a move).
  3. Replace setLen + raw write with beginStore / endStore.
  4. Drop now-redundant prepareMutation calls before indexed writes and beginStore.
  5. Don’t assume addr s[0] is stable across add, reassignment, or passing the string by value.
  6. Build once with --strings:sso and once without to confirm the code compiles and behaves identically under both.

Because the helper API has identical signatures on every string backend, code written this way keeps working under refc/ARC/ORC without SSO; you can port incrementally and flip --strings:sso on when ready.

Once again, in order to avoid confusion:

Updating your code is not required, but beneficial. The new API works for all mm switches and is also offered by Nimony/Nim 3.

Parallel and incremental builds (IC)

IC is finally shipping! Use it with the switch --ic:on. Parallel builds are based on multi-processing, not multi-threading. Hence macros that make use of global .compileTime variables might need to get patched before you can enjoy the benefits of much faster rebuilds.

For an example of the required porting effort, see my PR here that allows PolyWorld to be built with --ic:on!

Depending on the shape of your import graphs, even cold builds might be faster with IC turned on, as the build exploits more parallelism. For example, compiling the Nim compiler itself takes 31s with --ic:off on a machine with 16 physical cores, and 25s with --ic:on.

YRC – the missing piece of Nim’s MM story

YRC (“ürk”) is the thread-safe variant of Nim’s ORC. Use --mm:yrc when you need shared refs and cycles across threads. It offers the same API as ORC. Where it fits:

mm-switch Thread-safe Can handle cycles
ARC No No
Atomic ARC Yes No
ORC No Yes
YRC Yes Yes

ORC can only handle cycles that stay inside one thread. YRC lifts that: cycles can span threads. For acyclic data it uses atomic reference counting; for cyclic data it uses a write barrier that only runs at pointer assignments. There is no stop-the-world phase and no arbitrary safepoints. Any thread can run the cycle collector when needed (there is no dedicated GC thread), which helps locality and suits embedded systems where every thread counts.

Nothing is free. In our orcbench, YRC is about 2.0× slower than ORC and it uses twice as much memory, too. Still, we think that’s a good trade: you no longer have to worry about cycles crossing thread boundaries. The cost is a constant factor of two; the payoff grows with every core you put to work on shared data.

Sum types

Nim always offered Ada-inspired “discriminated unions”, for example:

type
  NodeKind = enum  # the different node types
    IntLit,        # a leaf with an integer value
    FloatLit,      # a leaf with a float value
    StringLit,     # a leaf with a string value
    AddOp,         # an addition
    SubOp,         # a subtraction
    IfStmt         # an if statement
  Node = ref object
    case kind: NodeKind
    of IntLit: intVal: int
    of FloatLit: floatVal: float
    of StringLit: strVal: string
    of AddOp, SubOp:
      leftOp, rightOp: Node
    of IfStmt:
      condition, thenPart, elsePart: Node

While this works, people complained about the lack of concise notation for years. So version 2.4 finally offers something better:

type
  Node = ref object
    case
    of IntLit: intVal: int
    of FloatLit: floatVal: float
    of StringLit: strVal: string
    of AddOp, SubOp:
      leftOp, rightOp: Node
    of IfStmt:
      condition, thenPart, elsePart: Node

There is no NodeKind enum anymore and no kind field: the branch names are introduced by the type declaration itself. Under the hood a sum type is still a case object with a hidden discriminator, so the memory layout and the performance are the same as before. (However, in the future the pattern matching allows for the runtime checks to be eliminated!)

A branch name constructs the object, with named arguments for the fields of its branch:

let n = AddOp(leftOp: IntLit(intVal: 40), rightOp: IntLit(intVal: 2))

Generic sum types work too. The instance is inferred from the field values or from the expected type:

type
  Opt[T] = object
    case
    of None: discard
    of Some: val: T

let a = Some(val: "abc")      # Opt[string]
let b: Opt[int] = None()      # the expected type selects the instance

The fields can only be accessed via pattern matching, so accessing the field of the wrong branch, the classic FieldDefect, cannot happen anymore. The fields are bound to the names that follow the branch name:

proc eval(n: Node): int =
  case n
  of IntLit(v): v
  of AddOp(a, b): eval(a) + eval(b)
  of SubOp(a, b): eval(a) - eval(b)
  else: raiseAssert "not an arithmetic expression"

_ skips a field, {AddOp, SubOp}(a, b) matches several branches that share their fields, and the case has to cover all branches or have an else. If the matched expression is a mutable location, the bindings are views into the object and can be assigned to:

type
  Shape = object
    case
    of Circle: radius: float
    of Rect: w, h: float

var shapes = @[Circle(radius: 1.0), Rect(w: 1.0, h: 2.0)]
for i in 0 ..< shapes.len:
  case shapes[i]
  of Circle(r): r = r * 2       # updates shapes[i].radius
  of Rect(w, _): w = 3.0

Otherwise the fields are bound by value. If you really need to access a field outside of a case, there is an escape hatch: {.cast(uncheckedAccess).}: echo n.intVal.

Classic case objects continue to work and are not deprecated.

owned type modifier

This feature has to be enabled with --experimental:ownedRefs and I can promise you that we will break your code again if you use the feature.

owned ref T (and owned closures) mark the location that holds the unique owning edge to an object. This is checked statically: an owned location cannot be copied, only moved, and only fresh values like object constructions can initialize it. Any number of ordinary references to the same object can exist and keep it alive; the runtime representation is the same as that of ref T, so code with and without the feature interoperates.

{.experimental: "ownedRefs".}
type
  Node = ref object
    next: owned Node
    data: int

The point of the exercise is cycle collection: owned edges form a forest, so a type whose references are all owned or .cursor can never be part of a cycle and stays out of the cycle collector under ORC and YRC. See the experimental manual for the details.

Cyclic module dependencies

Nim always allowed modules to import each other, but in a cycle a module only saw the declarations of the other module that came before the import. In practice that meant juggling the order of imports and declarations, or moving all types into a types.nim module.

Import cycles are still a design smell as they turn the import DAG into a general graph, but sometimes they are the natural structure, and then the compiler should not stand in your way.

You can annotate the imports of a cycle with the cyclic pragma:

# module a
import b {.cyclic.}

type
  A* = object
    b*: B          # a type of `b`, even though `b` imports `a`

proc isEven*(n: int): bool = n == 0 or isOdd(n - 1)
# module b
import a {.cyclic.}

type
  B* = object
    x*: int
    a*: ref A

proc isOdd*(n: int): bool = n != 0 and isEven(n - 1)

The modules connected by such imports form a cycle group that is checked phase by phase: first the imports of all modules, then all type sections, then the routines (their bodies come later), then the rest of each module. So types can refer to each other across modules, and the procs of the modules can call each other, regardless of the order of the declarations.

It is good practice to annotate the imports on both sides of a cycle. Import cycles without the annotation still work as before, but are deprecated; compile with --warning:ImplicitCyclicImport:on to find them. The precise rules are described in the manual.

 

A complete list of changes is available here.