Skip to content

Conversation

@not-ronjinger
Copy link

@not-ronjinger not-ronjinger commented Dec 9, 2025

Motivation

Similar to NixOS#14753, but rebased for determinate nix.

The implementation in nixpkgs is slightly inefficient:

filterAttrs = pred: set: removeAttrs set (filter (name: !pred name set.${name}) (attrNames set));

Namely, it has to create a temporary list of names attrNames set, then has to scan through the entries with filter, apply the predicate which needs to dereference the value, then pass it to removeAttrs, which then needs to do another scan of names which failed the predicate. It's likely as good as you can get without creating a specific builtin.

With a native builtin, we should be able to eliminate the need for generating the temporary list, the initial scan should be a bit faster as there is less indirection with looking up the value, and the need to do another scan for removeAttrs should be eliminated altogether.

Re-introducing this to nixpkgs/lib should be easy with a builtins ? filterAttrs check.

Context

Gotta go fast 🚀

Summary by CodeRabbit

  • New Features

    • Added a filterAttrs builtin to filter attribute sets by applying a predicate to each name/value pair and returning a new attribute set with matching entries.
  • Tests

    • Added functional tests validating filterAttrs for name-based and value-based predicates, ensuring correct selection and output ordering of filtered attributes.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 9, 2025

Walkthrough

Adds a new primitive __filterAttrs that applies a predicate to each attribute name/value pair in an attrset and returns a new attrset containing only attributes for which the predicate returns true. Includes functional tests for name- and value-based filtering.

Changes

Cohort / File(s) Change Summary
Core primop implementation
src/libexpr/primops.cc
Adds prim_filterAttrs and registers primop_filterAttrs; validates arguments, iterates attributes, calls the predicate with (name, value), checks boolean result, collects matching attributes, and emits a sorted attrset.
Functional tests
tests/functional/lang/eval-okay-filterattrs.nix, tests/functional/lang/eval-okay-filterattrs.exp, tests/functional/lang/eval-okay-filterattrs-names.nix, tests/functional/lang/eval-okay-filterattrs-names.exp
Adds tests exercising builtins.filterAttrs for value-based filtering (keep values > 5) and name-based filtering (keep name == "a"); includes expected evaluation outputs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Inspect src/libexpr/primops.cc for correct argument arity/type checks, predicate invocation and error propagation, and memory/resource handling.
  • Verify attribute sorting/stability and that boolean coercion for predicate results is correct.
  • Confirm the four functional tests assert the intended behavior and cover edge cases (empty sets, single-match, multiple matches).

Poem

🐰
I hopped through sets both large and small,
I asked each name and value—answer, call!
Those who chirped "true" hopped into my cart,
The rest went wandering, gentle and smart. 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Add filterAttrs builtin" directly and concisely summarizes the main change: introducing a new builtin function. It is specific, clear, and accurately reflects the core objective of the PR.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/libexpr/primops.cc (1)

3508-3549: prim_filterAttrs implementation looks correct; consider a small perf refactor

The logic matches the usual filterAttrs pattern: you force the input attrset, short‑circuit the empty case, force f once, iterate attrs in sorted order, call f name value, and rebuild a sorted subset. That’s semantically in line with the nixpkgs/lib definition and other primops like filter/mapAttrs.

One minor performance improvement: you allocate a temporary Value (vFun2) for each attribute just to partially apply f to the name. You can avoid that per‑attribute heap allocation by using the multi‑argument callFunction overload directly, as done in foldl':

-    for (auto & i : *args[1]->attrs()) {
-        Value * vName = Value::toPtr(state.symbols[i.name]);
-        Value * vFun2 = state.allocValue();
-        vFun2->mkApp(args[0], vName);
-        Value res;
-        state.callFunction(*vFun2, *i.value, res, noPos);
-        if (state.forceBool(res, pos, "while evaluating the return value of the filtering function passed to builtins.filterAttrs"))
-            attrs.insert(i.name, i.value);
-    }
+    for (auto & i : *args[1]->attrs()) {
+        Value * vName = Value::toPtr(state.symbols[i.name]);
+        Value * vs[]{vName, i.value};
+        Value res;
+        state.callFunction(*args[0], vs, res, noPos);
+        if (state.forceBool(
+                res,
+                pos,
+                "while evaluating the return value of the filtering function passed to builtins.filterAttrs"))
+            attrs.insert(i.name, i.value);
+    }

Optionally, you could mirror prim_filter’s same optimization (track whether any attrs were actually dropped and, if not, just return the original set) to avoid rebuilding when the predicate is always true, but that’s a non‑essential micro‑opt.

src/libexpr-tests/primops.cc (1)

323-346: filterAttrs tests cover core behavior; optional extra case for name‑only predicates

These tests correctly exercise the happy path (keeping only attributes with value > 5) and the all‑false case, and they force the surviving values to ensure they evaluate as expected. That gives good coverage of the new primop.

If you want slightly broader coverage, you could add an extra test where f depends only on the attribute name (e.g. name == "foo") to assert that the name argument is wired correctly and that values are only forced when the predicate chooses to use them, but that’s optional.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 52ea293 and a348f46.

📒 Files selected for processing (2)
  • src/libexpr-tests/primops.cc (1 hunks)
  • src/libexpr/primops.cc (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/libexpr/primops.cc (2)
src/libexpr/include/nix/expr/attr-set.hh (4)
  • pos (399-404)
  • pos (399-399)
  • pos (406-411)
  • pos (406-406)
src/libexpr/include/nix/expr/eval.hh (24)
  • pos (712-713)
  • pos (729-736)
  • pos (759-759)
  • pos (764-764)
  • pos (769-773)
  • pos (790-790)
  • pos (904-904)
  • pos (1042-1042)
  • v (654-657)
  • v (654-654)
  • v (659-659)
  • v (665-665)
  • v (670-670)
  • v (671-671)
  • v (672-672)
  • v (674-674)
  • v (679-679)
  • v (683-683)
  • v (684-684)
  • v (685-690)
  • v (691-691)
  • v (710-710)
  • v (875-875)
  • v (952-952)
src/libexpr-tests/primops.cc (2)
src/libexpr/eval.cc (20)
  • eval (1150-1153)
  • eval (1150-1150)
  • eval (1188-1191)
  • eval (1188-1188)
  • eval (1193-1196)
  • eval (1193-1193)
  • eval (1198-1201)
  • eval (1198-1198)
  • eval (1203-1206)
  • eval (1203-1203)
  • eval (1208-1211)
  • eval (1208-1208)
  • eval (1225-1320)
  • eval (1225-1225)
  • eval (1322-1344)
  • eval (1322-1322)
  • b (3291-3294)
  • b (3291-3291)
  • state (1084-1107)
  • state (1084-1084)
src/libexpr/include/nix/expr/attr-set.hh (2)
  • a (38-41)
  • a (38-38)

Copy link
Collaborator

@edolstra edolstra left a comment

Choose a reason for hiding this comment

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

LGTM. Do you have any metrics on how much this helps with stuff like Nixpkgs/NixOS evaluation?

@not-ronjinger not-ronjinger force-pushed the add-filterattrs-detsys branch 2 times, most recently from d6ed2cc to 8962520 Compare December 11, 2025 00:04
@not-ronjinger
Copy link
Author

not-ronjinger commented Dec 11, 2025

LGTM. Do you have any metrics on how much this helps with stuff like Nixpkgs/NixOS evaluation?

Here's a trivial example, showing roughly a 5x decrease in time and memory for just the filterAttrs call:

$ NIX_SHOW_STATS=1  time /nix/store/n97c5d761cx6rap4jbfyh7rm61a0blgn-determinate-nix-3.14.0/bin/nix-instantiate --eval -E 'with import ./. { allowAliases = false; }; lib.filterAttrs (_: v: v == "directory") (builtins.readDir ./pkgs/development/python-modules)' > /dev/null
{
  "cpuTime": 0.09902500361204147,
  "envs": {
    "bytes": 2772064,
    "elements": 190142,
    "number": 156366
  },
  "gc": {
    "cycles": 1,
    "heapSize": 4295229440,
    "totalBytes": 37539568
  },
  "list": {
    "bytes": 373904,
    "concats": 1222,
    "elements": 46738
  },
  "maxWaiting": 0,
  "nrAvoided": 142030,
  "nrExprs": 98670,
  "nrFunctionCalls": 147065,
  "nrLookups": 74178,
  "nrOpUpdateValuesCopied": 1153522,
  "nrOpUpdates": 5024,
  "nrPrimOpCalls": 59705,
  "nrSpuriousWakeups": 0,
  "nrThunks": 161819,
  "nrThunksAwaited": 0,
  "nrThunksAwaitedSlow": 0,
  "sets": {
    "bytes": 24388864,
    "elements": 1504024,
    "number": 13520
  },
  "sizes": {
    "Attr": 16,
    "Bindings": 24,
    "Env": 8,
    "Value": 16
  },
  "symbols": {
    "bytes": 1485792,
    "number": 43403
  },
  "time": {
    "cpu": 0.09902500361204147,
    "gc": 0.001,
    "gcFraction": 0.010098459616500329
  },
  "values": {
    "bytes": 8695104,
    "number": 543444
  },
  "waitingTime": 0.0
}
0.09user 0.04system 0:00.13elapsed 101%CPU (0avgtext+0avgdata 89292maxresident)k
0inputs+0outputs (0major+17303minor)pagefaults 0swaps
[16:06:41] jon@jon-desktop /home/jon/projects/nixpkgs (master)
$ NIX_SHOW_STATS=1  time /nix/store/n97c5d761cx6rap4jbfyh7rm61a0blgn-determinate-nix-3.14.0/bin/nix-instantiate --eval -E 'with import ./. { allowAliases = false; }; builtins.filterAttrs (_: v: v == "directory") (builtins.readDir ./pkgs/development/python-modules)' > /dev/null
{
  "cpuTime": 0.018790999427437782,
  "envs": {
    "bytes": 294584,
    "elements": 18475,
    "number": 18348
  },
  "gc": {
    "cycles": 1,
    "heapSize": 4295229440,
    "totalBytes": 748800
  },
  "list": {
    "bytes": 24,
    "concats": 0,
    "elements": 3
  },
  "maxWaiting": 0,
  "nrAvoided": 1,
  "nrExprs": 66,
  "nrFunctionCalls": 18346,
  "nrLookups": 2,
  "nrOpUpdateValuesCopied": 0,
  "nrOpUpdates": 0,
  "nrPrimOpCalls": 2,
  "nrSpuriousWakeups": 0,
  "nrThunks": 3,
  "nrThunksAwaited": 0,
  "nrThunksAwaitedSlow": 0,
  "sets": {
    "bytes": 295824,
    "elements": 18480,
    "number": 6
  },
  "sizes": {
    "Attr": 16,
    "Bindings": 24,
    "Env": 8,
    "Value": 16
  },
  "symbols": {
    "bytes": 331008,
    "number": 9441
  },
  "time": {
    "cpu": 0.018790999427437782,
    "gc": 0.0,
    "gcFraction": 0.0
  },
  "values": {
    "bytes": 148880,
    "number": 9305
  },
  "waitingTime": 0.0
}
0.01user 0.01system 0:00.02elapsed 100%CPU (0avgtext+0avgdata 36952maxresident)k
0inputs+0outputs (0major+2811minor)pagefaults 0swaps

I don't think filterAttrs is used super often in evaluating nixpkgs, but seems like it might be useful for module eval

@not-ronjinger
Copy link
Author

not-ronjinger commented Dec 11, 2025

Seems to be single digit % improvement for nixos eval. But it helps!

With my changes (replaced lib.filterAttrs and ran a second time to avoid writing .drv overhead)

[16:20:11] jon@jon-desktop /home/jon/projects/nixpkgs (master)
$ NIX_SHOW_STATS=1  /nix/store/n97c5d761cx6rap4jbfyh7rm61a0blgn-determinate-nix-3.14.0/bin/nix-instantiate ./nixos/release.nix -A iso_minimal.x86_64-linux
warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/ficzsx2vb6qss0qs7vpwvfbkzwifdh20-nixos-minimal-26.05pre708350.gfedcba-x86_64-linux.iso.drv
{
  "cpuTime": 5.114654064178467,
  "envs": {
    "bytes": 316897520,
    "elements": 22518224,
    "number": 17093966
  },
  "gc": {
    "cycles": 1,
    "heapSize": 4295229440,
    "totalBytes": 1385633104
  },
  "list": {
    "bytes": 29785680,
    "concats": 439370,
    "elements": 3723210
  },
  "maxWaiting": 0,
  "nrAvoided": 20152773,
  "nrExprs": 1451270,
  "nrFunctionCalls": 14644664,
  "nrLookups": 7654703,
  "nrOpUpdateValuesCopied": 12227949,
  "nrOpUpdates": 799421,
  "nrPrimOpCalls": 8897065,
  "nrSpuriousWakeups": 0,
  "nrThunks": 19005108,
  "nrThunksAwaited": 0,
  "nrThunksAwaitedSlow": 0,
  "sets": {
    "bytes": 470672736,
    "elements": 24299733,
    "number": 3411542
  },
  "sizes": {
    "Attr": 16,
    "Bindings": 24,
    "Env": 8,
    "Value": 16
  },
  "symbols": {
    "bytes": 3473424,
    "number": 96897
  },
  "time": {
    "cpu": 5.114654064178467,
    "gc": 0.024,
    "gcFraction": 0.004692399466092721
  },
  "values": {
    "bytes": 419128304,
    "number": 26195519
  },
  "waitingTime": 0.0
}

Unmodified behavior (ran a second time to avoid writing .drv overhead)

[16:20:24] jon@jon-desktop /home/jon/projects/nixpkgs (master)
$ git stash -- .
Saved working directory and index state WIP on master: 9cfae7bb77c5 lunar-client: 3.5.9 -> 3.5.11 (#466794)
[16:20:57] jon@jon-desktop /home/jon/projects/nixpkgs (master)
$ NIX_SHOW_STATS=1  /nix/store/n97c5d761cx6rap4jbfyh7rm61a0blgn-determinate-nix-3.14.0/bin/nix-instantiate ./nixos/release.nix -A iso_minimal.x86_64-linux
warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/ij59jm36svir83l3m2b1cmbrbxhm4rnz-nixos-minimal-26.05pre708350.gfedcba-x86_64-linux.iso.drv
{
  "cpuTime": 5.139573097229004,
  "envs": {
    "bytes": 320172928,
    "elements": 22722937,
    "number": 17298679
  },
  "gc": {
    "cycles": 1,
    "heapSize": 4295229440,
    "totalBytes": 1391501888
  },
  "list": {
    "bytes": 31391832,
    "concats": 439370,
    "elements": 3923979
  },
  "maxWaiting": 0,
  "nrAvoided": 20357486,
  "nrExprs": 1451285,
  "nrFunctionCalls": 14849377,
  "nrLookups": 7846586,
  "nrOpUpdateValuesCopied": 12227949,
  "nrOpUpdates": 799421,
  "nrPrimOpCalls": 8909627,
  "nrSpuriousWakeups": 0,
  "nrThunks": 19216102,
  "nrThunksAwaited": 0,
  "nrThunksAwaitedSlow": 0,
  "sets": {
    "bytes": 470672736,
    "elements": 24299733,
    "number": 3411542
  },
  "sizes": {
    "Attr": 16,
    "Bindings": 24,
    "Env": 8,
    "Value": 16
  },
  "symbols": {
    "bytes": 3473424,
    "number": 96897
  },
  "time": {
    "cpu": 5.139573097229004,
    "gc": 0.025,
    "gcFraction": 0.0048642172272009765
  },
  "values": {
    "bytes": 419429632,
    "number": 26214352
  },
  "waitingTime": 0.0
}

Copy link
Member

@RossComputerGuy RossComputerGuy left a comment

Choose a reason for hiding this comment

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

Image

Comment on lines 3524 to 3526
vFun2->mkApp(args[0], vName);
Value res;
state.callFunction(*vFun2, *i.value, res, noPos);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this mkApp() needed? You can call callFunction() with multiple arguments, which would save an allocation.

Copy link
Author

Choose a reason for hiding this comment

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

No, it's not. Wasn't aware, I'll change.

Copy link
Author

@not-ronjinger not-ronjinger Dec 12, 2025

Choose a reason for hiding this comment

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

changed to just passing both. No real effect on performance:

$ NIX_SHOW_STATS=1 /nix/store/b4qdkw8wdlfzm3gagrs5d8j7abh93xhj-determinate-nix-3.14.0/bin/nix-instantiate --eval -E 'with import ./. { allowAliases = false; }; lib.filterAttrs (_: v: v == "directory") (builtins.readDir ./pkgs/development/python-modules)' > /dev/null
{
  "cpuTime": 0.09584499895572662,
  "envs": {
    "bytes": 2772064,
    "elements": 190142,
    "number": 156366
  },
  "gc": {
    "cycles": 1,
    "heapSize": 4295229440,
    "totalBytes": 37539568
  },
  "list": {
    "bytes": 373904,
    "concats": 1222,
    "elements": 46738
  },
  "maxWaiting": 0,
  "nrAvoided": 142030,
  "nrExprs": 98670,
  "nrFunctionCalls": 147065,
  "nrLookups": 74178,
  "nrOpUpdateValuesCopied": 1153522,
  "nrOpUpdates": 5024,
  "nrPrimOpCalls": 59705,
  "nrSpuriousWakeups": 0,
  "nrThunks": 161819,
  "nrThunksAwaited": 0,
  "nrThunksAwaitedSlow": 0,
  "sets": {
    "bytes": 24388864,
    "elements": 1504024,
    "number": 13520
  },
  "sizes": {
    "Attr": 16,
    "Bindings": 24,
    "Env": 8,
    "Value": 16
  },
  "symbols": {
    "bytes": 1485792,
    "number": 43403
  },
  "time": {
    "cpu": 0.09584499895572662,
    "gc": 0.001,
    "gcFraction": 0.010433512555641289
  },
  "values": {
    "bytes": 8695104,
    "number": 543444
  },
  "waitingTime": 0.0
}

@not-ronjinger not-ronjinger force-pushed the add-filterattrs-detsys branch 2 times, most recently from a627178 to d6e05fd Compare December 12, 2025 16:16
@edolstra edolstra enabled auto-merge December 12, 2025 18:44
auto-merge was automatically disabled December 12, 2025 19:33

Head branch was pushed to by a user without write access

@not-ronjinger
Copy link
Author

Fixed the test linting

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/libexpr/primops.cc (1)

3508-3549: Consider a “no-op” fast-path + better error position for predicate calls. This primop always builds a new attrset; if the predicate keeps all attrs, returning the original set avoids allocations and preserves sharing. Also, callFunction(..., noPos) can make predicate errors harder to locate.

 static void prim_filterAttrs(EvalState & state, const PosIdx pos, Value ** args, Value & v)
 {
     state.forceAttrs(*args[1], pos, "while evaluating the second argument passed to builtins.filterAttrs");

     if (args[1]->attrs()->empty()) {
         v = *args[1];
         return;
     }

     state.forceFunction(*args[0], pos, "while evaluating the first argument passed to builtins.filterAttrs");

     auto attrs = state.buildBindings(args[1]->attrs()->size());
+    bool same = true;

     for (auto & i : *args[1]->attrs()) {
         Value * vName = Value::toPtr(state.symbols[i.name]);
         Value * callArgs[] = {vName, i.value};
         Value res;
-        state.callFunction(*args[0], callArgs, res, noPos);
+        state.callFunction(*args[0], callArgs, res, pos /* or i.pos if preferred/available */);
         if (state.forceBool(
                 res, pos, "while evaluating the return value of the filtering function passed to builtins.filterAttrs"))
             attrs.insert(i.name, i.value);
+        else
+            same = false;
     }

-    v.mkAttrs(attrs.alreadySorted());
+    if (same)
+        v = *args[1];
+    else
+        v.mkAttrs(attrs.alreadySorted());
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d6e05fd and 9c96d74.

📒 Files selected for processing (5)
  • src/libexpr/primops.cc (1 hunks)
  • tests/functional/lang/eval-okay-filterattrs-names.exp (1 hunks)
  • tests/functional/lang/eval-okay-filterattrs-names.nix (1 hunks)
  • tests/functional/lang/eval-okay-filterattrs.exp (1 hunks)
  • tests/functional/lang/eval-okay-filterattrs.nix (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • tests/functional/lang/eval-okay-filterattrs.exp
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/functional/lang/eval-okay-filterattrs-names.exp
  • tests/functional/lang/eval-okay-filterattrs.nix
🧰 Additional context used
🧬 Code graph analysis (1)
src/libexpr/primops.cc (2)
src/libexpr/include/nix/expr/attr-set.hh (4)
  • pos (399-404)
  • pos (399-399)
  • pos (406-411)
  • pos (406-406)
src/libexpr/include/nix/expr/eval.hh (24)
  • pos (712-713)
  • pos (729-736)
  • pos (759-759)
  • pos (764-764)
  • pos (769-773)
  • pos (790-790)
  • pos (904-904)
  • pos (1042-1042)
  • v (654-657)
  • v (654-654)
  • v (659-659)
  • v (665-665)
  • v (670-670)
  • v (671-671)
  • v (672-672)
  • v (674-674)
  • v (679-679)
  • v (683-683)
  • v (684-684)
  • v (685-690)
  • v (691-691)
  • v (710-710)
  • v (875-875)
  • v (952-952)
🔇 Additional comments (1)
tests/functional/lang/eval-okay-filterattrs-names.nix (1)

1-5: Nice minimal name-based test for builtins.filterAttrs. Covers the 2-arg predicate shape and verifies name-driven filtering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants