Skip to content

Commit

Permalink
feat: Add intrinsic to get if running inside an unconstrained context (
Browse files Browse the repository at this point in the history
…#5098)

# Description

## Problem\*

Currently, when creating a regular (not marked with unconstrained)
function, it can end up running in both a constrained and unconstrained
context.

## Summary\*

This intrinsic is resolved at compile time a provides a way for
developers to dispatch to different implementations in library code for
constrained and unconstrained context. This intrinsic has been used in
stdlib's field comparison code as demonstration.

## Additional Context

It's very typical for the constrained version of a calculation to take a
hint of a computed value and verify it. However, in the unconstrained
version of that calculation we just need to compute the value, there is
no point in verifying what we just did. This intrinsic provides a tool
for devs to optimize a function for unconstrained removing these
verifications.

## Documentation\*

Check one:
- [ ] No documentation needed.
- [x] Documentation included in this PR.
- [ ] **[For Experimental Features]** Documentation to be submitted in a
separate PR.

# PR Checklist\*

- [x] I have tested the changes locally.
- [x] I have formatted the changes with [Prettier](https://prettier.io/)
and/or `cargo fmt` on default settings.

---------

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>
  • Loading branch information
sirasistant and TomAFrench authored May 24, 2024
1 parent 86fd0ac commit 281ebf2
Show file tree
Hide file tree
Showing 15 changed files with 263 additions and 76 deletions.
1 change: 1 addition & 0 deletions compiler/noirc_evaluator/src/ssa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub(crate) fn optimize_into_acir(
.run_pass(Ssa::defunctionalize, "After Defunctionalization:")
.run_pass(Ssa::remove_paired_rc, "After Removing Paired rc_inc & rc_decs:")
.run_pass(Ssa::inline_functions, "After Inlining:")
.run_pass(Ssa::resolve_is_unconstrained, "After Resolving IsUnconstrained:")
// Run mem2reg with the CFG separated into blocks
.run_pass(Ssa::mem2reg, "After Mem2Reg:")
.run_pass(Ssa::as_slice_optimization, "After `as_slice` optimization")
Expand Down
6 changes: 5 additions & 1 deletion compiler/noirc_evaluator/src/ssa/ir/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub(crate) enum Intrinsic {
FromField,
AsField,
AsWitness,
IsUnconstrained,
}

impl std::fmt::Display for Intrinsic {
Expand All @@ -89,6 +90,7 @@ impl std::fmt::Display for Intrinsic {
Intrinsic::FromField => write!(f, "from_field"),
Intrinsic::AsField => write!(f, "as_field"),
Intrinsic::AsWitness => write!(f, "as_witness"),
Intrinsic::IsUnconstrained => write!(f, "is_unconstrained"),
}
}
}
Expand Down Expand Up @@ -116,7 +118,8 @@ impl Intrinsic {
| Intrinsic::SliceRemove
| Intrinsic::StrAsBytes
| Intrinsic::FromField
| Intrinsic::AsField => false,
| Intrinsic::AsField
| Intrinsic::IsUnconstrained => false,

// Some black box functions have side-effects
Intrinsic::BlackBox(func) => matches!(func, BlackBoxFunc::RecursiveAggregation),
Expand Down Expand Up @@ -145,6 +148,7 @@ impl Intrinsic {
"from_field" => Some(Intrinsic::FromField),
"as_field" => Some(Intrinsic::AsField),
"as_witness" => Some(Intrinsic::AsWitness),
"is_unconstrained" => Some(Intrinsic::IsUnconstrained),
other => BlackBoxFunc::lookup(other).map(Intrinsic::BlackBox),
}
}
Expand Down
1 change: 1 addition & 0 deletions compiler/noirc_evaluator/src/ssa/ir/instruction/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ pub(super) fn simplify_call(
SimplifyResult::SimplifiedToInstruction(instruction)
}
Intrinsic::AsWitness => SimplifyResult::None,
Intrinsic::IsUnconstrained => SimplifyResult::None,
}
}

Expand Down
1 change: 1 addition & 0 deletions compiler/noirc_evaluator/src/ssa/opt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ mod rc;
mod remove_bit_shifts;
mod remove_enable_side_effects;
mod remove_if_else;
mod resolve_is_unconstrained;
mod simplify_cfg;
mod unrolling;
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ impl Context {
| Intrinsic::FromField
| Intrinsic::AsField
| Intrinsic::AsSlice
| Intrinsic::AsWitness => false,
| Intrinsic::AsWitness
| Intrinsic::IsUnconstrained => false,
},

// We must assume that functions contain a side effect as we cannot inspect more deeply.
Expand Down
3 changes: 2 additions & 1 deletion compiler/noirc_evaluator/src/ssa/opt/remove_if_else.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ fn slice_capacity_change(
| Intrinsic::BlackBox(_)
| Intrinsic::FromField
| Intrinsic::AsField
| Intrinsic::AsWitness => SizeChange::None,
| Intrinsic::AsWitness
| Intrinsic::IsUnconstrained => SizeChange::None,
}
}
56 changes: 56 additions & 0 deletions compiler/noirc_evaluator/src/ssa/opt/resolve_is_unconstrained.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use crate::ssa::{
ir::{
function::{Function, RuntimeType},
instruction::{Instruction, Intrinsic},
types::Type,
value::Value,
},
ssa_gen::Ssa,
};
use acvm::FieldElement;
use fxhash::FxHashSet as HashSet;

impl Ssa {
/// An SSA pass to find any calls to `Intrinsic::IsUnconstrained` and replacing any uses of the result of the intrinsic
/// with the resolved boolean value.
/// Note that this pass must run after the pass that does runtime separation, since in SSA generation an ACIR function can end up targeting brillig.
#[tracing::instrument(level = "trace", skip(self))]
pub(crate) fn resolve_is_unconstrained(mut self) -> Self {
for func in self.functions.values_mut() {
replace_is_unconstrained_result(func);
}
self
}
}

fn replace_is_unconstrained_result(func: &mut Function) {
let mut is_unconstrained_calls = HashSet::default();
// Collect all calls to is_unconstrained
for block_id in func.reachable_blocks() {
for &instruction_id in func.dfg[block_id].instructions() {
let target_func = match &func.dfg[instruction_id] {
Instruction::Call { func, .. } => *func,
_ => continue,
};

if let Value::Intrinsic(Intrinsic::IsUnconstrained) = &func.dfg[target_func] {
is_unconstrained_calls.insert(instruction_id);
}
}
}

for instruction_id in is_unconstrained_calls {
let call_returns = func.dfg.instruction_results(instruction_id);
let original_return_id = call_returns[0];

// We replace the result with a fresh id. This will be unused, so the DIE pass will remove the leftover intrinsic call.
func.dfg.replace_result(instruction_id, original_return_id);

let is_within_unconstrained = func.dfg.make_constant(
FieldElement::from(matches!(func.runtime(), RuntimeType::Brillig)),
Type::bool(),
);
// Replace all uses of the original return value with the constant
func.dfg.set_value_from_id(original_return_id, is_within_unconstrained);
}
}
59 changes: 59 additions & 0 deletions docs/docs/noir/standard_library/is_unconstrained.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: Is Unconstrained Function
description:
The is_unconstrained function returns wether the context at that point of the program is unconstrained or not.
keywords:
[
unconstrained
]
---

It's very common for functions in circuits to take unconstrained hints of an expensive computation and then verify it. This is done by running the hint in an unconstrained context and then verifying the result in a constrained context.

When a function is marked as unconstrained, any subsequent functions that it calls will also be run in an unconstrained context. However, if we are implementing a library function, other users might call it within an unconstrained context or a constrained one. Generally, in an unconstrained context we prefer just computing the result instead of taking a hint of it and verifying it, since that'd mean doing the same computation twice:

```rust

fn my_expensive_computation(){
...
}

unconstrained fn my_expensive_computation_hint(){
my_expensive_computation()
}

pub fn external_interface(){
my_expensive_computation_hint();
// verify my_expensive_computation: If external_interface is called from unconstrained, this is redundant
...
}

```

In order to improve the performance in an unconstrained context you can use the function at `std::runtime::is_unconstrained() -> bool`:


```rust
use dep::std::runtime::is_unconstrained;

fn my_expensive_computation(){
...
}

unconstrained fn my_expensive_computation_hint(){
my_expensive_computation()
}

pub fn external_interface(){
if is_unconstrained() {
my_expensive_computation();
} else {
my_expensive_computation_hint();
// verify my_expensive_computation
...
}
}

```

The is_unconstrained result is resolved at compile time, so in unconstrained contexts the compiler removes the else branch, and in constrained contexts the compiler removes the if branch, reducing the amount of compute necessary to run external_interface.
Loading

0 comments on commit 281ebf2

Please sign in to comment.