Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Thunks tutorial #824

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions source/tutorials/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ Packaging existing software <packaging-existing-software.md>
file-sets.md
nixos/index.md
cross-compilation.md
thunks.md
module-system/module-system.md
```
151 changes: 151 additions & 0 deletions source/tutorials/thunks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
(thunks)=
# Thunks

In Nix, _thunks_ are used to implement laziness.
A thunk is a type of Nix value that is not yet evaluated.
It is only evaluated once needed.
It consists of two parts:
- The expression that the value should be evaluated from
- The variables the expression has access to
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nix also has tApp thunks, which are functions that haven't really been applied yet.
It's used in cases where Nix knows which functions and arguments need to be paired up, but haven't been demanded yet. Example: map f [e1 e2], tApp is used for f e1 and f e2 thunks, because for the primop there's no expression that represents these applications.
Other thunks may exist, but either way, and partly because such details may not be permanent, I believe the representation of thunks is not actually that relevant.
They're just delayed computations.

What may be relevant is that call by need is implemented by mutating an object in memory, changing it from a representation of a delayed computation, to a representation of a value that is in a weak head normal form.


It is very easy to introduce a lot of thunks in Nix code, which can have negative consequences:

- Every new thunk requires heap memory allocations.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is 100% true, but Values on the stack are cheap anyway and can not be allowed to be referenced by any expression whose lifetime extends beyond that of the stack frame, making their use somewhat limited (although we have plenty on-stack values at various points, I must say).

Also allocations for let should be cheaper than other allocations.

- A thunk prevents the evaluation garbage collector from collecting any variables it needs,
causing not only the memory of the thunk itself to be kept alive, but also all its references.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such references are often referred to as a closure, and the term applies at least at a conceptual level, where we can say that the free variables are closed over.

Nix is perhaps simplistic in how it represents closures: a reference to a singly linked list is retained, which has all scopes all the way to the top of the file, even if no references are made to many of the variables in those scope layers. (Env is a what I would call a layer here)

- Too deeply nested thunks can lead to stack overflows when evaluated.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun fact: if Nix changes its forceValue from an if (isThunk) to a while (isThunk) it could do a bunch of tail recursion, but traces might be worse or more expensive.

May not solve the problem for the mutual nesting of thunks and e.g. attrsets, so this stands. Probably for other patterns as well.


Of course, thunks are essential to Nix, so it's not possible to avoid them.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Of course, thunks are essential to Nix, so it's not possible to avoid them.
Thunks are essential to the implementation Nix, or any lazy functional language, so it's not possible to avoid them.

Less subjective and more informative respectively.

And in fact, using thunks properly can improve performance.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to refer to the deduplication of computation?
OTOH we don't have much of a choice usually. Littering the code with seqs seems awful other reasons.


## When thunks are created

The rules of thunk creation in Nix are relatively straightforward.

1. Nix won't create thunks for atomic expressions

- This includes integers, floats, strings, paths, booleans and null
- This means that when you see e.g. "hello" or true in Nix, you know that Nix won't allocate a thunk for that. The reason for this is that there's not much to evaluate there, putting it into a thunk wouldn't make much sense.
- Note however that this is not the case for "Hello ${name}", because that is desugared to "Hello " + name underneath, which won't be a string by itself anymore

2. Nix won't create thunks for referenced variables

- This means that once you defined a variable in a let in expression, or you're in a function that received some arguments at the top, Nix won't create extra thunks for when you reference these variables.
- It makes a lot of sense for Nix to do this, because variables themselves already point to either a thunk or an evaluated value, which can be used directly and doesn't need to be wrapped in another thunk that would just say "Evaluate this variable".
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- It makes a lot of sense for Nix to do this, because variables themselves already point to either a thunk or an evaluated value, which can be used directly and doesn't need to be wrapped in another thunk that would just say "Evaluate this variable".
- Nix can do this, because (except for a dynamic scope error involving `with`), they refer to memory locations that have been previously allocated and are properly initialized with thunks or values, and it has precomputed two index numbers to look them up rather efficiently. Doing the work tends to be cheaper than allocating a thunk to represent "Evaluate this variable".


3. The following expressions attempt to create thunks if allowed by above two rules

- `let ... in` expressions attempt to create a thunk for each variable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `let ... in` expressions attempt to create a thunk for each variable
- `let ... in` expressions allocate a thunk for each variable

I believe these are contiguous and therefore cheaper as a payoff from #8285, but don't quote me on that :)

- `{ ... }` (attribute set) expressions attempt to create a thunk for each attribute
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `{ ... }` (attribute set) expressions attempt to create a thunk for each attribute
- `{ ... }` (attribute set) literals may or may not allocate thunk, depending on the expression for the attribute value

It calls maybeThunk, which either allocates or returns a pointer to an existing location. ExprVar does the latter, as hinted before, but other expressions may have to allocate. A function application seems like a prime example, unless I guess we have the mythical Sufficiently Smart JIT.

- `[ ... ]` (list) expressions attempt to create a thunk for each element
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `[ ... ]` (list) expressions attempt to create a thunk for each element
- `[ ... ]` (list) literals behave similarly to attribute values

- `f a` (function application) expressions attempt to create a thunk for the argument
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It think it depends. The arguments are pointers on the stack (since recently up to 4, falling back to the heap, but that's actually a lot for currying), and those pointers can be acquired through maybeThunk ie eg ExprVar doesn't need to allocate. The return value may be written to the stack.
That's if the call needs to be made directly. Otherwise you might be looking at a tApp from a higher order function primop or something.

The allocation of an Env seems more certain in this situation. All it takes for that is that the function is not a primop.
Partially applied primops do allocate thunks though. Isn't this fun.

- `{ attr ? def }: ...`:
For every function evaluation where the function takes an attribute set where an attribute has a default value which doesn't exist in the passed argument,
a thunk for the default value is attempted to be created.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
a thunk for the default value is attempted to be created.
a thunk for the default value is to be created.

What's this "attempting" about?
If it fails, it crashes and it seems to be an unlikely cause, or not really worth considering for the purpose of optimization.
I would think it's for the final value. More something representing an ExprSelect with the default. (Probably even a fake ExprSelect, but that's unnecessary detail.)


## Example

The following shows an illustrative example of when and how many thunks are allocated.
The comments give the thunk counts and explanation in the format

```nix
# total (+difference) Explanation
```

```nix
# let in expressions can allocate thunks
let

# 0 (+0) No thunk allocated because strings are atomic value expressions
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 0 (+0) No thunk allocated because strings are atomic value expressions
# 0 (+0) No thunk allocated because simple string literals in the parsed expression are accompanied by a reusable value which does not even start as a thunk.

name = "Paul";

# 1 (+1) Thunk is allocated, because the + operator is neither an atomic
# value nor a direct variable
greeting = "Hello, " + name;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
greeting = "Hello, " + name;
greeting = "Hello, " + "world";

We don't have general constant expression elimination, and I don't think strings are an exception. (but strings are special, so maybe check)


# 1 (+0) No thunk is allocated because greeting is a direct variable
result1 = greeting;

# 2 (+1) This let in variable creates a new thunk, but the attribute itself
# isn't evaluated, meaning it also won't create any thunks for its values
deadAttrs = {
deadName = "dead " + "value";
};

# 4 (+2) One for the variable, but also one for the value inside, because the
# attribute itself is evaluated
attrs = {
name = "alive " + "value";
};

# 5 (+1) Intermediate result, forcing evaluation of the attrs, one thunk for
# the variable declaration. Note that this is a function call, but because
# both arguments are variables, no extra thunks are allocated
result2 = builtins.seq attrs null;

# 7 (+2) Same with a list which is evaluated
list = [
("alive " + "value")
];

# 8 (+1) And again, another intemediate result
result3 = builtins.seq list result2;

# 10 (+2) Just two variables, a normal function doesn't allocate a thunk on
# its own
fun = a: a;
result4 = builtins.seq fun result3;

# 13 (+3) However if the function is applied to a non-atom, non-variable
# value, a thunk for the argument is created
app = fun (1 + 1);
result5 = builtins.seq app result4;

# 16 (+3) A function with a default attribute argument can allocate a thunk
# for the default argument if it isn't passed
attrApp = ({ notPassed ? 1 + 1, ... }: null) attrs;
result6 = builtins.seq attrApp result5;


# Let bindings can allocate thunks even if their variables are unused
# -> Push let bindings outside as much as possible!
# 16 (+2) Outer let bindings
# 19 (+3) inner let bindings, one for every iteration
# 23 (+2) for the two non-atomic/non-variable function argument
# 23 (+0) Atomic value list elements
lets = map (x:
let y = 1 + 1;
in x
) [ 1 2 3 ];
result7 = builtins.deepSeq lets result6;

# 24 (+1) One variable
values = [ 1 2 3 ];

# 27 (+3) Three variables next
# 27 (+0) Function receives variable as argument -> no extra thunk
elAt = builtins.elemAt values;
# 27 (+0) Function called three times, but all arguments don't need thunks
elAtApp = elAt 0 + elAt 1 + elAt 2;
result8 = builtins.seq elAtApp result7;

# 29 (+2) Two variables next
# 29 (+0) Function (and nested function) called three times,
# but all arguments don't need thunks
elemAtApp = builtins.elemAt values 0
+ builtins.elemAt values 1
+ builtins.elemAt values 2;
result9 = builtins.seq elemAtApp result8;

in result9
```

We can verify the final thunk count by using the [`NIX_SHOW_STATS`](https://nixos.org/manual/nix/stable/command-ref/env-common.html?highlight=NIX_SHOW_STATS#env-NIX_SHOW_STATS) environment variable.

```shell-session
$ NIX_SHOW_STATS=1 NIX_SHOW_STATS_PATH=stats.json \
nix-instantiate --eval thunks.nix
null
$ jq .nrThunks stats.json
29
```