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

[mlir] Attempt to resolve edge cases in PassPipeline textual format #118877

Merged
merged 1 commit into from
Dec 18, 2024

Conversation

christopherbate
Copy link
Contributor

@christopherbate christopherbate commented Dec 5, 2024

This commit makes the following changes:

  1. Previously certain pipeline options could cause the options parser to
    get stuck in an an infinite loop. An example is:

    mlir-opt %s -verify-each=false -pass-pipeline='builtin.module(func.func(test-options-super-pass{list={list=1,2},{list=3,4}}))''
    

    In this example, the 'list' option of the test-options-super-pass
    is itself a pass options specification (this capability was added in
    [mlir] Add support for parsing nested PassPipelineOptions #101118).

    However, while the textual format allows ListOption<int> to be given
    as list=1,2,3, it did not allow the same format for
    ListOption<T> when T is a subclass of PassOptions without extra
    enclosing {....}. Lack of enclosing {...} would cause the infinite
    looping in the parser.

    This change resolves the parser bug and also allows omitting the
    outer {...} for ListOption-of-options.

  2. Previously, if you specified a default list value for your
    ListOption, e.g. ListOption<int> opt{*this, "list", llvm::cl::list_init({1,2,3})},
    it would be impossible to override that default value of {1,2,3} with
    an empty list on the command line, since my-pass{list=} was not allowed.

    This was not allowed because of ambiguous handling of lists-of-strings
    (no literal marker is currently required).

    This change makes it explicit in the ListOption construction that we
    would like to treat all ListOption as having a default value of "empty"
    unless otherwise specified (e.g. using llvm::list_init).

    It removes the requirement that lists are not printed if empty. Instead,
    lists are not printed if they do not have their default value.

    It is now clarified that the textual format
    my-pass{string-list=""} or my-pass{string-list={}}
    is interpreted as "empty list". This makes it imposssible to specify
    that ListOption string-list should be a size-1 list containing the
    empty string. However, my-pass{string-list={"",""}} does specify
    a size-2 list containing the empty string. This behavior seems preferable
    to allow for overriding non-empty defaults as described above.

@llvmbot llvmbot added mlir:core MLIR Core Infrastructure mlir labels Dec 5, 2024
@llvmbot
Copy link
Member

llvmbot commented Dec 5, 2024

@llvm/pr-subscribers-mlir

@llvm/pr-subscribers-mlir-core

Author: Christopher Bate (christopherbate)

Changes

This commit makes the following changes:

  1. Previously certain pipeline options could cause the options parser to
    get stuck in an an infinite loop. An example is:

    mlir-opt %s -verify-each=false -pass-pipeline='builtin.module(func.func(test-options-super-pass{list={list=1,2},{list=3,4}}))''
    

    In this example, the 'list' option of the test-options-super-pass
    is itself a pass options specification (this capability was added in
    [mlir] Add support for parsing nested PassPipelineOptions #101118).

    However, while the textual format allows ListOption&lt;int&gt; to be given
    as list=1,2,3, it did not allow the same format for
    ListOption&lt;T&gt; when T is a subclass of PassOptions without extra
    enclosing {....}. Lack of enclosing {...} would cause the infinite
    looping in the parser.

    This change resolves the parser bug and also allows omitting the
    outer {...} for ListOption-of-options.

  2. Previously, if you specified a default list value for your
    ListOption, e.g. ListOption&lt;int&gt; opt{*this, "list", llvm::list_init({1,2,3})},
    it would be impossible to override that default value of {1,2,3} with
    an empty list on the command line, since my-pass{list=} was not allowed.

    This was not allowed because of ambiguous handling of lists-of-strings
    (no literal marker is currently required).

    This updates this behvior so that specifying empty lists (either
    list={} or just list= is allowed.


Full diff: https://github.com/llvm/llvm-project/pull/118877.diff

5 Files Affected:

  • (modified) mlir/include/mlir/Pass/PassOptions.h (+13-4)
  • (modified) mlir/lib/Pass/PassRegistry.cpp (+36-25)
  • (modified) mlir/test/Pass/pipeline-options-parsing.mlir (+18-1)
  • (modified) mlir/test/Transforms/inlining-dump-default-pipeline.mlir (+1-1)
  • (modified) mlir/test/lib/Pass/TestPassManager.cpp (+8-2)
diff --git a/mlir/include/mlir/Pass/PassOptions.h b/mlir/include/mlir/Pass/PassOptions.h
index a5a3f1c1c19652..3312f1c11b0bad 100644
--- a/mlir/include/mlir/Pass/PassOptions.h
+++ b/mlir/include/mlir/Pass/PassOptions.h
@@ -19,6 +19,7 @@
 #include "llvm/ADT/StringRef.h"
 #include "llvm/Support/CommandLine.h"
 #include "llvm/Support/Compiler.h"
+#include "llvm/Support/Debug.h"
 #include <memory>
 
 namespace mlir {
@@ -297,10 +298,18 @@ class PassOptions : protected llvm::cl::SubCommand {
 
     /// Print the name and value of this option to the given stream.
     void print(raw_ostream &os) final {
-      // Don't print the list if empty. An empty option value can be treated as
-      // an element of the list in certain cases (e.g. ListOption<std::string>).
-      if ((**this).empty())
-        return;
+      // Don't print the list if the value is the default value. An empty
+      // option value should never be treated as an empty list.
+      if (this->isDefaultAssigned() &&
+          this->getDefault().size() == (**this).size()) {
+        unsigned i = 0;
+        for (unsigned e = (**this).size(); i < e; i++) {
+          if (!this->getDefault()[i].compare((**this)[i]))
+            break;
+        }
+        if (i == (**this).size())
+          return;
+      }
 
       os << this->ArgStr << "={";
       auto printElementFn = [&](const DataType &value) {
diff --git a/mlir/lib/Pass/PassRegistry.cpp b/mlir/lib/Pass/PassRegistry.cpp
index fe842755958418..167abdc4dddab2 100644
--- a/mlir/lib/Pass/PassRegistry.cpp
+++ b/mlir/lib/Pass/PassRegistry.cpp
@@ -186,6 +186,27 @@ const PassPipelineInfo *mlir::PassPipelineInfo::lookup(StringRef pipelineArg) {
 // PassOptions
 //===----------------------------------------------------------------------===//
 
+static size_t findChar(StringRef str, size_t index, char c) {
+  for (size_t i = index, e = str.size(); i < e; ++i) {
+    if (str[i] == c)
+      return i;
+    // Check for various range characters.
+    if (str[i] == '{')
+      i = findChar(str, i + 1, '}');
+    else if (str[i] == '(')
+      i = findChar(str, i + 1, ')');
+    else if (str[i] == '[')
+      i = findChar(str, i + 1, ']');
+    else if (str[i] == '\"')
+      i = str.find_first_of('\"', i + 1);
+    else if (str[i] == '\'')
+      i = str.find_first_of('\'', i + 1);
+    if (i == StringRef::npos)
+      return StringRef::npos;
+  }
+  return StringRef::npos;
+}
+
 /// Extract an argument from 'options' and update it to point after the arg.
 /// Returns the cleaned argument string.
 static StringRef extractArgAndUpdateOptions(StringRef &options,
@@ -194,47 +215,37 @@ static StringRef extractArgAndUpdateOptions(StringRef &options,
   options = options.drop_front(argSize).ltrim();
 
   // Early exit if there's no escape sequence.
-  if (str.size() <= 2)
+  if (str.size() <= 1)
     return str;
 
   const auto escapePairs = {std::make_pair('\'', '\''),
-                            std::make_pair('"', '"'), std::make_pair('{', '}')};
+                            std::make_pair('"', '"')};
   for (const auto &escape : escapePairs) {
     if (str.front() == escape.first && str.back() == escape.second) {
       // Drop the escape characters and trim.
-      str = str.drop_front().drop_back().trim();
       // Don't process additional escape sequences.
-      break;
+      return str.drop_front().drop_back().trim();
     }
   }
 
+  // Arguments may be wrapped in `{...}`. Unlike the quotation markers that
+  // denote literals, we respect scoping here. The outer `{...}` should not
+  // be stripped in cases such as "arg={...},{...}", which can be used to denote
+  // lists of nested option structs.
+  if (str.front() == '{') {
+    unsigned match = findChar(str, 1, '}');
+    if (match == str.size() - 1)
+      str = str.drop_front().drop_back().trim();
+  }
+
   return str;
 }
 
 LogicalResult detail::pass_options::parseCommaSeparatedList(
     llvm::cl::Option &opt, StringRef argName, StringRef optionStr,
     function_ref<LogicalResult(StringRef)> elementParseFn) {
-  // Functor used for finding a character in a string, and skipping over
-  // various "range" characters.
-  llvm::unique_function<size_t(StringRef, size_t, char)> findChar =
-      [&](StringRef str, size_t index, char c) -> size_t {
-    for (size_t i = index, e = str.size(); i < e; ++i) {
-      if (str[i] == c)
-        return i;
-      // Check for various range characters.
-      if (str[i] == '{')
-        i = findChar(str, i + 1, '}');
-      else if (str[i] == '(')
-        i = findChar(str, i + 1, ')');
-      else if (str[i] == '[')
-        i = findChar(str, i + 1, ']');
-      else if (str[i] == '\"')
-        i = str.find_first_of('\"', i + 1);
-      else if (str[i] == '\'')
-        i = str.find_first_of('\'', i + 1);
-    }
-    return StringRef::npos;
-  };
+  if (optionStr.empty())
+    return success();
 
   size_t nextElePos = findChar(optionStr, 0, ',');
   while (nextElePos != StringRef::npos) {
diff --git a/mlir/test/Pass/pipeline-options-parsing.mlir b/mlir/test/Pass/pipeline-options-parsing.mlir
index b6c2b688b7cfb3..c23c3a2e3d0d0c 100644
--- a/mlir/test/Pass/pipeline-options-parsing.mlir
+++ b/mlir/test/Pass/pipeline-options-parsing.mlir
@@ -14,6 +14,19 @@
 // RUN: mlir-opt %s -verify-each=false '-test-options-super-pass-pipeline=super-list={{enum=zero list=1 string=foo},{enum=one list=2 string="bar"},{enum=two list=3 string={baz}}}' -dump-pass-pipeline 2>&1 | FileCheck --check-prefix=CHECK_7 %s
 // RUN: mlir-opt %s -verify-each=false -pass-pipeline='builtin.module(func.func(test-options-super-pass{list={{enum=zero list={1} string=foo },{enum=one list={2} string=bar },{enum=two list={3} string=baz }}}))' -dump-pass-pipeline 2>&1 | FileCheck --check-prefix=CHECK_7 %s
 
+
+// This test checks that lists-of-nested-options like 'option1={...},{....}' can be parsed
+// just like how 'option=1,2,3' is also allowed:
+
+// RUN: mlir-opt %s -verify-each=false -pass-pipeline='builtin.module(func.func(test-options-super-pass{list={enum=zero list={1} string=foo },{enum=one list={2} string=bar },{enum=two list={3} string=baz }}))' -dump-pass-pipeline 2>&1 | FileCheck --check-prefix=CHECK_7 %s
+
+// This test checks that it is legal to specify an empty list using '{}'.
+// RUN: mlir-opt %s -verify-each=false '--test-options-super-pass=list={enum=zero list={1} string=foo},{enum=one list={} default-list= string=bar}' -dump-pass-pipeline 2>&1 | FileCheck --check-prefix=CHECK_8 %s
+
+// This test checks that it is possible to specify lists of empty strings.
+// RUN: mlir-opt %s -verify-each=false '--test-options-pass=string-list="",""' '--test-options-pass=string-list=""' -dump-pass-pipeline 2>&1 | FileCheck --check-prefix=CHECK_9 %s
+
+
 // CHECK_ERROR_1: missing closing '}' while processing pass options
 // CHECK_ERROR_2: no such option test-option
 // CHECK_ERROR_3: no such option invalid-option
@@ -26,4 +39,8 @@
 // CHECK_4: builtin.module(builtin.module(func.func(test-options-pass{enum=zero list={3} string= }),func.func(test-options-pass{enum=one list={1,2,3,4} string=foobar })))
 // CHECK_5: builtin.module(builtin.module(func.func(test-options-pass{enum=zero list={3} string= }),func.func(test-options-pass{enum=one list={1,2,3,4} string={foo bar baz} })))
 // CHECK_6: builtin.module(builtin.module(func.func(test-options-pass{enum=zero list={3} string= }),func.func(test-options-pass{enum=one list={1,2,3,4} string=foo"bar"baz })))
-// CHECK_7{LITERAL}: builtin.module(func.func(test-options-super-pass{list={{enum=zero list={1} string=foo },{enum=one list={2} string=bar },{enum=two list={3} string=baz }}}))
+// CHECK_7{LITERAL}: builtin.module(func.func(test-options-super-pass{list={{ enum=zero list={1} string=foo },{ enum=one list={2} string=bar },{ enum=two list={3} string=baz }}}))
+
+// CHECK_8{LITERAL}: builtin.module(func.func(test-options-super-pass{list={{ enum=zero list={1} string=foo },{default-list={} enum=one list={} string=bar }}}))
+// CHECK_9: builtin
+
diff --git a/mlir/test/Transforms/inlining-dump-default-pipeline.mlir b/mlir/test/Transforms/inlining-dump-default-pipeline.mlir
index 4f8638054206e8..102310400e9d0b 100644
--- a/mlir/test/Transforms/inlining-dump-default-pipeline.mlir
+++ b/mlir/test/Transforms/inlining-dump-default-pipeline.mlir
@@ -1,2 +1,2 @@
 // RUN: mlir-opt %s -pass-pipeline="builtin.module(inline)" -dump-pass-pipeline 2>&1 | FileCheck %s
-// CHECK: builtin.module(inline{default-pipeline=canonicalize inlining-threshold=4294967295 max-iterations=4 })
+// CHECK: builtin.module(inline{default-pipeline=canonicalize inlining-threshold=4294967295 max-iterations=4 op-pipelines={} })
diff --git a/mlir/test/lib/Pass/TestPassManager.cpp b/mlir/test/lib/Pass/TestPassManager.cpp
index 7afe2109f04db3..a350a2f0931be7 100644
--- a/mlir/test/lib/Pass/TestPassManager.cpp
+++ b/mlir/test/lib/Pass/TestPassManager.cpp
@@ -59,8 +59,13 @@ struct TestOptionsPass
   struct Options : public PassPipelineOptions<Options> {
     ListOption<int> listOption{*this, "list",
                                llvm::cl::desc("Example list option")};
+    ListOption<int> listWithDefaultsOption{
+        *this, "default-list",
+        llvm::cl::desc("Example list option with defaults"),
+        llvm::cl::list_init<int>({10, 9, 8})};
     ListOption<std::string> stringListOption{
-        *this, "string-list", llvm::cl::desc("Example string list option")};
+        *this, "string-list", llvm::cl::desc("Example string list option"),
+        llvm::cl::list_init<std::string>({})};
     Option<std::string> stringOption{*this, "string",
                                      llvm::cl::desc("Example string option")};
     Option<Enum> enumOption{
@@ -94,7 +99,8 @@ struct TestOptionsPass
   ListOption<int> listOption{*this, "list",
                              llvm::cl::desc("Example list option")};
   ListOption<std::string> stringListOption{
-      *this, "string-list", llvm::cl::desc("Example string list option")};
+      *this, "string-list", llvm::cl::desc("Example string list option"),
+      llvm::cl::list_init<std::string>({})};
   Option<std::string> stringOption{*this, "string",
                                    llvm::cl::desc("Example string option")};
   Option<Enum> enumOption{

@christopherbate
Copy link
Contributor Author

christopherbate commented Dec 5, 2024

Note: this still doesn't handle empty string literals correctly. I'm not sure what to do there other than disallow having non-empty llvm::list_init for ListOption<std::string>

I updated the behavior, description, and tests to explicity demonstrate that constructing a ListOption<std::string> of size-1 containing the empty string is not possible/not allowed. The same logic would apply for any element type that could be parsed from the empty string.

Copy link
Contributor

@River707 River707 left a comment

Choose a reason for hiding this comment

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

Will review in the next day, my brain needs to carefully read this given how finicky the parsing here can be,

mlir/lib/Pass/PassRegistry.cpp Show resolved Hide resolved
mlir/test/lib/Pass/TestPassManager.cpp Outdated Show resolved Hide resolved
mlir/test/Pass/pipeline-options-parsing.mlir Outdated Show resolved Hide resolved
This commit makes the following changes:

1. Previously certain pipeline options could cause the options parser to
   get stuck in an an infinite loop. An example is:

   ```
   mlir-opt %s -verify-each=false -pass-pipeline='builtin.module(func.func(test-options-super-pass{list={list=1,2},{list=3,4}}))''
   ```

   In this example, the 'list' option of the `test-options-super-pass`
   is itself a pass options specification (this capability was added in
   llvm#101118).

   However, while the textual format allows `ListOption<int>` to be given
   as `list=1,2,3`, it did not allow the same format for
   `ListOption<T>` when T is a subclass of `PassOptions` without extra
   enclosing `{....}`. Lack of enclosing `{...}` would cause the infinite
   looping in the parser.

   This change resolves the parser bug and also allows omitting the
   outer `{...}` for `ListOption`-of-options.

2. Previously, if you specified a default list value for your
   `ListOption`, e.g. `ListOption<int> opt{*this, "list", llvm::list_init({1,2,3})}`,
   it would be impossible to override that default value of `{1,2,3}` with
   an *empty* list on the command line, since `my-pass{list=}` was not allowed.

   This was not allowed because of ambiguous handling of lists-of-strings
   (no literal marker is currently required).

   This change makes it explicit in the ListOption construction that we
   would like to treat all ListOption as having a default value of "empty"
   unless otherwise specified (e.g. using `llvm::list_init`).

   It removes the requirement that lists are not printed if empty. Instead,
   lists are not printed if they do not have their default value.

   It is now clarified that the textual format
   `my-pass{string-list=""}` or `my-pass{string-list={}}`
   is interpreted as "empty list". This makes it imposssible to specify
   that ListOption `string-list` should be a size-1 list containing the
   empty string. However, `my-pass{string-list={"",""}}` *does* specify
   a size-2 list containing the empty string. This behavior seems preferable
   to allow for overriding non-empty defaults as described above.
@christopherbate christopherbate force-pushed the fix-pass-pipeline-parser branch from a892f8d to 04d231a Compare December 17, 2024 23:32
@christopherbate christopherbate merged commit 1a70420 into llvm:main Dec 18, 2024
8 checks passed
@j2kun
Copy link
Contributor

j2kun commented Dec 18, 2024

This PR seems to change the behavior of a string option with no default, when passing the empty string my-pass{option=}. Is it expected that this means you cannot override an unset string option with an empty string?

@joker-eph
Copy link
Collaborator

Wouldn't this be: my-pass{option=""}?

j2kun added a commit to j2kun/heir that referenced this pull request Dec 22, 2024
Cf. google@ccba176#diff-6f8c01b4d228f97d2a800c5b5deca07240dd1203a0063e775b6a9f8e49cc9b9c
for the change that introduced this, due to a failure caused by upstream
changes.

llvm/llvm-project#118877 (comment)
for the suggested correct form.
j2kun added a commit to j2kun/heir that referenced this pull request Dec 22, 2024
Cf. google@ccba176#diff-6f8c01b4d228f97d2a800c5b5deca07240dd1203a0063e775b6a9f8e49cc9b9c
for the change that introduced this, due to a failure caused by upstream
changes.

llvm/llvm-project#118877 (comment)
for the suggested correct form.
@j2kun
Copy link
Contributor

j2kun commented Dec 22, 2024

@joker-eph that method still seems to fail. It seems that to define an empty string I have to actually use the list={} syntax, which is strange (it's even strange that list={} allows this given the option is not typed as a list).

See for example google/heir@ccba176#diff-6f8c01b4d228f97d2a800c5b5deca07240dd1203a0063e775b6a9f8e49cc9b9c the file tests/Dialect/Polynomial/Transforms/elementwise_to_affine.mlir which corresponds to the option defined here: https://github.com/google/heir/blob/276bc7c53ad9b074ad3b8dde2756b1980e4c2707/lib/Transforms/ElementwiseToAffine/ElementwiseToAffine.td#L31

I am on vacation for a few weeks so I may not have time to repro this upstream.

@christopherbate
Copy link
Contributor Author

Sorry just saw the post-merge discussion.

This PR seems to change the behavior of a string option with no default, when passing the empty string my-pass{option=}. Is it expected that this means you cannot override an unset string option with an empty string?

I don't think it was intended that the syntax be changed for plain string options. An option of type std::string with no default has an empty string value, however (because it gets default-constructed).

It seems that to define an empty string I have to actually use the list={} syntax, which is strange (it's even strange that list={} allows this given the option is not typed as a list).

The example you gave in your last message is a ListOption? Are you saying there's a problem with Option<...., "std::string",...> or ListOption<..., "std::string",...>?

@j2kun
Copy link
Contributor

j2kun commented Jan 2, 2025

@christopherbate indeed, the option is defined as

    ListOption<"convertOps","convert-ops", "std::string",
                "comma-separated list of ops to run this pass on ">,

I guess it's not so important to preserve that particular syntax, if an unset string option cannot be distinguished from an option set to empty string anyway. It's a bit weird that the syntax is invalid now, and also weird that setting it to an empty list (even though it's a string option) is valid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
mlir:core MLIR Core Infrastructure mlir
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants