-
-
Notifications
You must be signed in to change notification settings - Fork 260
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
base: master
Are you sure you want to change the base?
Thunks tutorial #824
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||
|
||||||
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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
- 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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. ( |
||||||
- Too deeply nested thunks can lead to stack overflows when evaluated. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fun fact: if Nix changes its 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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Less subjective and more informative respectively. |
||||||
And in fact, using thunks properly can improve performance. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trying to refer to the deduplication of computation? |
||||||
|
||||||
## 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". | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
It calls |
||||||
- `[ ... ]` (list) expressions attempt to create a thunk for each element | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
- `f a` (function application) expressions attempt to create a thunk for the argument | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The allocation of an |
||||||
- `{ 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. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
What's this "attempting" about? |
||||||
|
||||||
## 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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
name = "Paul"; | ||||||
|
||||||
# 1 (+1) Thunk is allocated, because the + operator is neither an atomic | ||||||
# value nor a direct variable | ||||||
greeting = "Hello, " + name; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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 | ||||||
``` |
There was a problem hiding this comment.
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 forf e1
andf 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.