diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index d058b20b..85608a45 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -17,6 +17,7 @@ import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.benchmarks.gen.ManyUnruledFieldsMessage; +import build.buf.protovalidate.benchmarks.gen.NumericRangeMessage; import build.buf.protovalidate.benchmarks.gen.RegexPatternMessage; import build.buf.protovalidate.benchmarks.gen.RepeatedRuleMessage; import build.buf.protovalidate.benchmarks.gen.SimpleStringMessage; @@ -42,6 +43,7 @@ public class ValidationBenchmark { private ManyUnruledFieldsMessage manyUnruled; private RepeatedRuleMessage repeatedRule; private RegexPatternMessage regexPattern; + private NumericRangeMessage numericRange; @Setup public void setup() throws ValidationException { @@ -71,11 +73,18 @@ public void setup() throws ValidationException { regexPattern = RegexPatternMessage.newBuilder().setName("Alice Example").build(); + NumericRangeMessage.Builder numericRangeBuilder = NumericRangeMessage.newBuilder(); + for (FieldDescriptor fd : NumericRangeMessage.getDescriptor().getFields()) { + numericRangeBuilder.setField(fd, 1.0f); + } + numericRange = numericRangeBuilder.build(); + // Warm evaluator cache for steady-state benchmarks. validator.validate(simple); validator.validate(manyUnruled); validator.validate(repeatedRule); validator.validate(regexPattern); + validator.validate(numericRange); } // Steady-state validate() benchmarks. These exercise the hot path after the @@ -100,4 +109,9 @@ public void validateRepeatedRule(Blackhole bh) throws ValidationException { public void validateRegexPattern(Blackhole bh) throws ValidationException { bh.consume(validator.validate(regexPattern)); } + + @Benchmark + public void validateNumericRange(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(numericRange)); + } } diff --git a/benchmarks/src/jmh/proto/bench/v1/bench.proto b/benchmarks/src/jmh/proto/bench/v1/bench.proto index d6fde070..dc1b6617 100644 --- a/benchmarks/src/jmh/proto/bench/v1/bench.proto +++ b/benchmarks/src/jmh/proto/bench/v1/bench.proto @@ -79,3 +79,32 @@ message RegexPatternMessage { max_bytes: 256 }]; } + +// Twenty float fields, each with only `gt` set. The standard float.gt rule +// expands into five CEL programs (gt, gt_lt, gt_lt_exclusive, gt_lte, +// gt_lte_exclusive); when neither `lt` nor `lte` is set, four of those +// short-circuit to the empty string using only the bound `rules` variable. +// Targets the residual-reduction skip in RuleCache: those four programs +// can be eliminated at compile time so they never run during validate(). +message NumericRangeMessage { + float f01 = 1 [(buf.validate.field).float.gt = 0]; + float f02 = 2 [(buf.validate.field).float.gt = 0]; + float f03 = 3 [(buf.validate.field).float.gt = 0]; + float f04 = 4 [(buf.validate.field).float.gt = 0]; + float f05 = 5 [(buf.validate.field).float.gt = 0]; + float f06 = 6 [(buf.validate.field).float.gt = 0]; + float f07 = 7 [(buf.validate.field).float.gt = 0]; + float f08 = 8 [(buf.validate.field).float.gt = 0]; + float f09 = 9 [(buf.validate.field).float.gt = 0]; + float f10 = 10 [(buf.validate.field).float.gt = 0]; + float f11 = 11 [(buf.validate.field).float.gt = 0]; + float f12 = 12 [(buf.validate.field).float.gt = 0]; + float f13 = 13 [(buf.validate.field).float.gt = 0]; + float f14 = 14 [(buf.validate.field).float.gt = 0]; + float f15 = 15 [(buf.validate.field).float.gt = 0]; + float f16 = 16 [(buf.validate.field).float.gt = 0]; + float f17 = 17 [(buf.validate.field).float.gt = 0]; + float f18 = 18 [(buf.validate.field).float.gt = 0]; + float f19 = 19 [(buf.validate.field).float.gt = 0]; + float f20 = 20 [(buf.validate.field).float.gt = 0]; +} diff --git a/src/main/java/build/buf/protovalidate/RuleCache.java b/src/main/java/build/buf/protovalidate/RuleCache.java index 9e6962bf..1e7d6610 100644 --- a/src/main/java/build/buf/protovalidate/RuleCache.java +++ b/src/main/java/build/buf/protovalidate/RuleCache.java @@ -31,6 +31,7 @@ import dev.cel.common.types.StructTypeReference; import dev.cel.runtime.CelEvaluationException; import dev.cel.runtime.CelRuntime.Program; +import dev.cel.runtime.CelVariableResolver; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -123,17 +124,41 @@ List compile( List programs = new ArrayList<>(); for (CelRule rule : completeProgramList) { Object fieldValue = message.getField(rule.field); + CelVariableResolver ruleResolver = + Variable.newRuleVariable(message, ProtoAdapter.toCel(rule.field, fieldValue)); + if (isTautological(rule.program, ruleResolver)) { + continue; + } programs.add( new CompiledProgram( rule.program, rule.astExpression.source, rule.rulePath, new ObjectValue(rule.field, fieldValue), - Variable.newRuleVariable(message, ProtoAdapter.toCel(rule.field, fieldValue)))); + ruleResolver)); } return Collections.unmodifiableList(programs); } + /** + * Evaluates the program with only rules/rule bound. If it resolves to true or "", the expression + * is tautological and can be eliminated (mirrors protovalidate-go's ReduceResiduals). + */ + private static boolean isTautological(Program program, CelVariableResolver ruleResolver) { + try { + Object result = program.eval(ruleResolver); + if (result instanceof Boolean) { + return (Boolean) result; + } + if (result instanceof String) { + return ((String) result).isEmpty(); + } + } catch (CelEvaluationException e) { + // Expression depends on unbound variables (this, now) — not tautological. + } + return false; + } + private @Nullable List compileRule( FieldDescriptor fieldDescriptor, boolean forItems,