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

Implement shell bypassing #429

Closed
wants to merge 1 commit into from
Closed

Implement shell bypassing #429

wants to merge 1 commit into from

Conversation

cipriancraciun
Copy link

As commented on #336, I've made an initial patch to allow one to disable the shell intermediation, and just parse the command as a list of words (perhaps with quoting).

As @sharkdp mentioned, I currently use --shell '' to disable the shell, but that is quite ugly UX; however I wanted to keep the patch as small as possible.

Also, given that Rust doesn't easily allow cross-compile I can't cargo check --target ...windows..., so although I've tried to get the code right on Windows most likely there is a compile error.


As mentioned in the issue at least in terms of outputs, it seems that hyperfine already handles the shell overhead and subtracts it from the outputs. Thus this patch is mostly to speed-up the overall execution by not passing through the shell. It shouldn't change the measured values.

@cipriancraciun
Copy link
Author

OK, thanks to the CI I was able to:

  • fix the Windows build; (I still can't actually test the Windows code, due to lack of Windows;)
  • apply cargo fmt;
  • squash all commits into a single one;

@Pietruszek
Copy link

I confirm that cargo check does finish correctly on Windows! I've also run cargo build and can confirm that using the resulting binary with the shell disabled, allows to bypass the issue #368.

However, I agree with @sharkdp that using the empty string to disable the shell is quite ugly - especially since on Windows you actually have to use --shell "" when running hyperfine in cmd.exe but --shell `"`" if you run it in powershell.exe (which I normally do)... Apparently when supplying an empty string as a parameter in powershell.exe, you need to escape the quatation marks, which I didn't know about.

@sharkdp
Copy link
Owner

sharkdp commented Oct 17, 2021

Thank you very much for your PR. Could you please fix the two "useless conversion" clippy warnings?

src/shell.rs Outdated
Comment on lines 30 to 34
return Ok(ExecuteResult {
status: std::os::windows::process::ExitStatusExt::from_raw(0),
system_time: 0.0,
user_time: 0.0,
});
Copy link
Owner

Choose a reason for hiding this comment

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

what is this for?

Copy link
Author

Choose a reason for hiding this comment

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

Previously, in order to compute the shell overhead, the execute_and_time command was called with an empty command, which in turn would run the equivalent of sh -c "" on Windows. (The same happens on Linux, but the code is simpler there as the timing is computed by hyperfine itserf.)

However given that one doesn't use a shell any more, and not to touch the other code too much, I've decided to just "fake" the execution of the empty command, with a success exit code and zero user and system time.

Copy link
Owner

Choose a reason for hiding this comment

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

and not to touch the other code too much

Feel free to touch the other code 😄. The current implementation is a bit "hacky", to be honest. I am not going to merge it in this state. But please feel free to reach out for help.

src/shell.rs Outdated
if shell == "" {
let mut tokens = match shell_words::split(command) {
Ok(tokens) => tokens.into_iter(),
Err(error) => return Err(io::Error::new(io::ErrorKind::Other, format!("{}", error))),
Copy link
Owner

Choose a reason for hiding this comment

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

We should really use a error handling crate like anyhow. Would make this much easier. But we can fix this later.

src/shell.rs Outdated
Comment on lines 118 to 122
fn prepare_shell_command(
command: &str,
shell: &str,
shell_arg: &str,
) -> io::Result<Option<(String, Vec<String>)>> {
Copy link
Owner

Choose a reason for hiding this comment

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

Can we please add a few simple tests for this function? We don't need to test the functionality of shell_words::split, but maybe a few edge cases that we could experience.

@sharkdp
Copy link
Owner

sharkdp commented Oct 17, 2021

I merged #436 in the meantime, knowing that this would introduce some conflicts for this PR. I'm sorry for this. Let me know if you need help resolving them.

@sharkdp
Copy link
Owner

sharkdp commented Nov 14, 2021

@cipriancraciun are you still interested in finishing this work?

@cipriancraciun
Copy link
Author

@cipriancraciun are you still interested in finishing this work?

Yes, but I'll have to make some time for this; hopefully in the next week or two.

@sharkdp
Copy link
Owner

sharkdp commented Nov 14, 2021

Fantastic. No hurries!

@Pietruszek
Copy link

A friendly reminder to @cipriancraciun

I've already been using the binary built from this PR for the past months, but it would also be great if these changes could eventually be incorporated to the main branch ;)

If the `--no-shell` argument is given,
then the command is split into tokens (using the `shell-words` crate),
and according to POSIX rules, the first token is taken as the executable,
and the rest as arguments.

This allows one to completely remove the shell overhead,
especially when the benchmarked command is extreemly quick.

Also, it allows one to make sure that the executed command
is not implemented as a shell builtin.
For example `hyperfine true` and `hyperfine --no-shell true`
return different times due to the fact that `bash` executes `true` as a NOP.

Example usage:
~~~~
hyperfine --no-shell -- true 'bash -c ""' "bash -c ''"
~~~~
@cipriancraciun
Copy link
Author

I've just pushed a new variant of the patch, one that is compatible with the current master.

The essentials are exactly like in the previous patch, except that now I've introduced --no-shell instead of --shell ''.

This time the patch is much more graceful due to the new Shell enum.

@sharkdp
Copy link
Owner

sharkdp commented Feb 20, 2022

@cipriancraciun Thank you very much! Just last week, I decided that I want to tackle this task (hyperfine without intermediate shell) myself after this had been open for some time. I started with a large-scale refactoring and cleanup that is still ongoing (see #482). Sorry if this caused a lot of conflicts for your PR.

I'm too deep into the refactoring process now and would like to finish that first. But I can then take care of merging your PR myself.

I am also planning to introduce a few breaking changes in the upcoming release of hyperfine (2.0). It could therefore make sense to think about the CLI again. Maybe we want to make the --no-shell variant the default?

I also have a slightly different implementation of this change in mind. Instead of having

/// Shell to use for executing benchmarked commands
#[derive(Debug)]
pub enum Shell {
    /// Default shell command
    Default(&'static str),

    /// Custom shell command specified via --shell
    Custom(Vec<String>),

    /// No shell, each command is parsed (via `shell-words`) and directly executed
    None,
}

I was thinking of something like a "benchmark executor" (or similar name) enum

pub enum Executor {
    Direct,
    ViaShell(Shell),
    Mocked(Fn() -> TimingResult),
}

where Exectuor::Direct would be the same as Shell::None in your implementation and Exector::ViaShell(Shell) would be the variant via a default or custom shell. The third variant is something that would be extremely helpful for writing proper integration tests for hyperfine. The problem with that is that actual runtimes are never reliable. I can't just run hyperfine "sleep 0.5" in a test and expect the mean runtime to be exactly 0.5s. I can't even put a proper upper limit to the runtime because in principle, the process might be sent to the background by the OS for a long time. Executor::Mocked(enforced_result) would maybe allow me to run hyperfine with a predetermined benchmark result for a specific command invocation.

@sharkdp
Copy link
Owner

sharkdp commented Feb 22, 2022

Closing this in favor of #487. Thank you very much for your initial work! I listed you as a co-author in #487.

@sharkdp sharkdp closed this Feb 22, 2022
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