diff --git a/CHANGES.md b/CHANGES.md index 1cc3d40..f8eb0e6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ 0.9.0 (unreleased) +- one polymake process now serves the whole GAP session instead of a fresh one + being started for every call, which makes a session of 40 calls about ten + times faster. Set the new `PolymakePersistent` preference to `false` for the + old behaviour. Note that polymake announces the third party software it uses + once per session, so those messages now appear once rather than once per + call. + - polymake 4.0 or newer is now required, and the GAP package json is a new dependency; json needs GAP 4.12, so that is now polymaking's minimum too. polymaking now writes and reads polymake's own JSON data format instead of the pre-4 plain format, which means polymake no longer converts diff --git a/doc/environment.xml b/doc/environment.xml index 5e3ee1e..bc5461a 100644 --- a/doc/environment.xml +++ b/doc/environment.xml @@ -23,9 +23,12 @@ standard output keeps the results clear of anything polymake prints. Using custom scripts is not supported.
-Every call to polymake will re-start the program anew. This causes considerable overhead. -The number of calls to polymake is reduced by caching the results in the so-called -PolymakeObject in GAP. +Starting polymake takes the best part of a second, nearly all of it spent +loading the rules of an application. By default one polymake process therefore +serves the whole &GAP; session, reading its instructions from a pipe and +answering each in a few milliseconds; see the PolymakePersistent +preference in . The number of calls is reduced +further by caching the results in the so-called PolymakeObject in GAP. As of polymaking version 0.9.0, polymake 4.0 or newer is required. Use polymaking 0.8.9 with older versions of polymake. @@ -69,6 +72,11 @@ there is nothing to worry about. "user" to honour ~/.polymake/settings instead; this is also considerably faster, as polymake then caches its autoconfiguration between calls. + PolymakePersistent + whether one polymake process serves the whole session, rather than + starting a new one for every call. true by default; starting + polymake takes the best part of a second, so this makes any session that + calls polymake more than once considerably faster. PolymakePreferences a list of polymake rule preferences applied to every call, for example [ "*.convex_hull cdd" ] to choose a convex hull diff --git a/lib/environment.gi b/lib/environment.gi index 132f30d..19fd029 100644 --- a/lib/environment.gi +++ b/lib/environment.gi @@ -61,6 +61,174 @@ end); ## to a file: polymake's own chatter goes to stderr, so a result on stdout could ## never be trusted. ## +## +## The persistent polymake. Starting polymake costs about 0.8s, almost all of it +## loading the rules of an application, and a session usually makes many calls; +## one long lived process answers them in about a millisecond each. Results +## still go to a file, so the pipe only ever carries a marker line. +## +BindGlobal("POLYMAKING_DONE", "__polymaking_done__"); + +# perl double quoted string literal +BindGlobal("POLYMAKING_PerlString", function(str) + local out, c; + out := "\""; + for c in str do + if c in "\\\"$@" then + Add(out, '\\'); + fi; + Add(out, c); + od; + Add(out, '\"'); + return out; +end); + +BindGlobal("POLYMAKING_Bool", b -> String(Number([b], x -> x = true))); + +BindGlobal("POLYMAKING_PerlList", + l -> Concatenation("[", JoinStringsWithSeparator( + List(l, POLYMAKING_PerlString), ","), "]")); + + +# Read until polymake reports the call finished. fail means the process died, +# in which case the caller retries once with a fresh one. +BindGlobal("POLYMAKING_AwaitDone", function(stream) + local line; + while true do + line := ReadLine(stream); + if line = fail then + return false; + fi; + line := Chomp(line); + if line = POLYMAKING_DONE then + return true; + elif line <> "" then + Info(InfoPolymaking, 2, line); + fi; + od; +end); + + +BindGlobal("POLYMAKING_StopServer", function() + if POLYMAKING_STATE.server <> fail then + if not IsClosedStream(POLYMAKING_STATE.server) then + CloseStream(POLYMAKING_STATE.server); + fi; + POLYMAKING_STATE.server := fail; + fi; +end); + + +# The settings baked into a running polymake: its config path and quiet flag are +# fixed when it starts, and rule preferences cannot be withdrawn once applied. +# Changing any of them means starting again. +BindGlobal("POLYMAKING_ServerSettings", function() + return [ PolymakeCommand(), + UserPreference("polymaking", "PolymakeConfigPath"), + UserPreference("polymaking", "PolymakeQuiet"), + UserPreference("polymaking", "PolymakePreferences") ]; +end); + + +BindGlobal("POLYMAKING_StartServer", function() + local cmd, stream, prelude; + + cmd := PolymakeCommand(); + if cmd = fail then + return fail; + fi; + stream := InputOutputLocalProcess(POLYMAKING_TempDirectory("scratch"), cmd, + ["--config-path", + UserPreference("polymaking", "PolymakeConfigPath"), "-"]); + if stream = fail then + return fail; + fi; + + prelude := Concatenation( + "do ", POLYMAKING_PerlString( + Filename(DirectoriesPackageLibrary("polymaking"), "pm.pl")), "; ", + "polymaking_setup(", + POLYMAKING_PerlString(POLYMAKING_ScratchFile("stderr.txt")), ", ", + POLYMAKING_Bool(UserPreference("polymaking", "PolymakeQuiet")), "); ", + "print ", POLYMAKING_PerlString(POLYMAKING_DONE), ", \"\\n\";"); + + if WriteLine(stream, prelude) = fail + or not POLYMAKING_AwaitDone(stream) then + CloseStream(stream); + return fail; + fi; + POLYMAKING_STATE.server := stream; + POLYMAKING_STATE.serverSettings := POLYMAKING_ServerSettings(); + return stream; +end); + + +BindGlobal("POLYMAKING_Result", function(resfile, errfile, status) + local err, res; + err := StringFile(errfile); + if err = fail then + err := ""; + fi; + if err <> "" then + Info(InfoPolymaking, 2, Chomp(err)); + fi; + res := StringFile(resfile); + if res = fail then + return rec(status := status, stderr := err, result := fail); + fi; + return rec(status := status, stderr := err, result := JsonStringToGap(res)); +end); + + +# Ask the persistent polymake; fail means it could not be used at all, so the +# caller falls back to starting polymake for this one call. +BindGlobal("POLYMAKING_RunServer", function(objfile, keywords, resfile) + local try, stream, call; + + if UserPreference("polymaking", "PolymakePersistent") <> true then + return fail; + fi; + + call := Concatenation( + "polymaking_eval(", POLYMAKING_PerlString(resfile), ", ", + POLYMAKING_PerlString(POLYMAKING_ScratchFile("stderr.txt")), ", ", + POLYMAKING_PerlString(objfile), ", ", + POLYMAKING_PerlList(UserPreference("polymaking", "PolymakePreferences")), + Concatenation(List(keywords, k -> Concatenation(", ", POLYMAKING_PerlString(k)))), + "); print ", POLYMAKING_PerlString(POLYMAKING_DONE), ", \"\\n\";"); + + # one retry, in case polymake died or was closed since the last call. Talking + # to a closed stream raises an error rather than returning fail, so the whole + # exchange goes through CALL_WITH_CATCH. + if POLYMAKING_STATE.server <> fail + and POLYMAKING_STATE.serverSettings <> POLYMAKING_ServerSettings() then + POLYMAKING_StopServer(); + fi; + + for try in [1, 2] do + stream := POLYMAKING_STATE.server; + if stream <> fail and IsClosedStream(stream) then + POLYMAKING_STATE.server := fail; + stream := fail; + fi; + if stream = fail then + stream := POLYMAKING_StartServer(); + if stream = fail then + return fail; + fi; + fi; + if CALL_WITH_CATCH(function() + return WriteLine(stream, call) <> fail + and POLYMAKING_AwaitDone(stream); + end, []) = [true, true] then + return true; + fi; + POLYMAKING_StopServer(); + od; + return fail; +end); + + InstallGlobalFunction(POLYMAKING_Run, function(dir, args) local cmd, errfile, resfile, scriptarg, p, out, status, err, res; @@ -76,6 +244,12 @@ InstallGlobalFunction(POLYMAKING_Run, function(dir, args) RemoveFile(errfile); RemoveFile(resfile); + # args is [objfile, keyword...], or ["--version"] + if Length(args) > 1 and POLYMAKING_RunServer(args[1], args{[2..Length(args)]}, + resfile) = true then + return POLYMAKING_Result(resfile, errfile, 0); + fi; + scriptarg := ["--config-path", UserPreference("polymaking","PolymakeConfigPath"), "--script", Filename(DirectoriesPackageLibrary("polymaking"), "pm.pl"), "--stderr", errfile]; @@ -92,19 +266,7 @@ InstallGlobalFunction(POLYMAKING_Run, function(dir, args) status := Process(dir, cmd, InputTextNone(), out, scriptarg); CloseStream(out); - err := StringFile(errfile); - if err = fail then - err := ""; - fi; - if err <> "" then - Info(InfoPolymaking, 2, Chomp(err)); - fi; - - res := StringFile(resfile); - if res = fail then - return rec(status := status, stderr := err, result := fail); - fi; - return rec(status := status, stderr := err, result := JsonStringToGap(res)); + return POLYMAKING_Result(resfile, errfile, status); end); @@ -164,6 +326,13 @@ end); POLYMAKING_UpdateLegacyGlobals(); +# a stream does not survive into another session +CallAndInstallPostRestore(function() + POLYMAKING_STATE.server := fail; +end); + +InstallAtExit(POLYMAKING_StopServer); + # the data directory a restored workspace names is gone, see issue #17 CallAndInstallPostRestore(POLYMAKING_UpdateLegacyGlobals); diff --git a/lib/pm.pl b/lib/pm.pl index 48d74c2..1dbd68f 100644 --- a/lib/pm.pl +++ b/lib/pm.pl @@ -1,67 +1,95 @@ # Evaluate polymake properties and write them to a JSON file for polymaking. # -# usage: pm.pl [OPTIONS] RESULTFILE OBJFILE KEYWORD... -# pm.pl [OPTIONS] RESULTFILE --version +# one shot: pm.pl [OPTIONS] RESULTFILE OBJFILE KEYWORD... +# pm.pl [OPTIONS] RESULTFILE --version +# persistent: do "pm.pl"; then call polymaking_setup and polymaking_eval, +# see POLYMAKING_StartServer in lib/environment.gi # # The result is a JSON object with the keys "version", "values" (keyword -> # serialized value), "errors" (keyword -> message) and, if the object could not # be loaded at all, "fatal". Writing it to a file rather than to stdout keeps it # clear of anything polymake prints. -my ($errfile, $quiet, @prefer); -while (@ARGV && $ARGV[0] =~ /^--/ && $ARGV[0] ne '--version') { - my $opt = shift(@ARGV); - last if $opt eq '--'; - if ($opt eq '--stderr') { $errfile = shift(@ARGV) } - elsif ($opt eq '--quiet') { $quiet = 1 } - elsif ($opt eq '--prefer') { push @prefer, shift(@ARGV) } - else { die "pm.pl: unknown option $opt\n" } -} - # Reassociating the glob also catches err_print/warn_print, which write to -# $Polymake::console, and polymake's own fatal error handler. -if (defined $errfile) { - open(STDERR, '>', $errfile) or die "cannot redirect stderr to $errfile: $!\n"; - STDERR->autoflush; +# $Polymake::console, and polymake's own fatal error handler. Verbose must be +# set before any load(), which consults Verbose::files. +sub polymaking_setup { + my ($errfile, $quiet) = @_; + if (defined($errfile) && length($errfile)) { + open(STDERR, '>', $errfile) or die "cannot redirect stderr to $errfile: $!\n"; + STDERR->autoflush; + } + if ($quiet) { + $Polymake::User::Verbose::credits = 0; + $Polymake::User::Verbose::files = 0; + } } -# Must happen before load(), which consults Verbose::files. -if ($quiet) { - $Polymake::User::Verbose::credits = 0; - $Polymake::User::Verbose::files = 0; -} +# lexical, not a package variable: under --script polymake compiles this with +# its own namespace pragma, which rejects `our`. +my %polymaking_applied; -my $out = shift(@ARGV); -my $file = shift(@ARGV); -my %r = (version => "$Polymake::Version", values => {}, errors => {}); +sub polymaking_eval { + my ($out, $errfile, $file, $prefer, @keywords) = @_; + my %r = (version => "$Polymake::Version", values => {}, errors => {}); -if (defined($file) && $file ne '--version') { - my $obj = eval { load($file) }; - if ($@) { - $r{fatal} = "$@"; - } else { - # Not Polymake::User::prefer_now: under --script $Polymake::User::application - # is a stub whose preferences are unset. Mode::create rather than the usual - # Mode::strict, so polymake does not consider its settings changed and - # rewrite them when a config path is in use. - $obj->type->application->prefs - ->add_preference($_, Polymake::Core::Preference::Mode::create) - for @prefer; + # Reopen rather than rely on the handle from polymaking_setup: the caller + # starts each call from a clean file, and a persistent process would otherwise + # go on writing to the old, unlinked one. + polymaking_setup($errfile, 0) if defined($errfile) && length($errfile); - for my $kw (@ARGV) { - my $v = eval { my $x = $obj; $x = $x->$_ for split /\./, $kw; $x }; - if ($@) { - $r{errors}{$kw} = "$@"; - } elsif (!defined($v)) { - $r{errors}{$kw} = "undefined"; - } else { - my $s = eval { Polymake::Core::Serializer::serialize($v) }; - $@ ? ($r{errors}{$kw} = "$@") : ($r{values}{$kw} = $s); + if (defined($file) && length($file)) { + my $obj = eval { Polymake::User::load($file) }; + if ($@) { + $r{fatal} = "$@"; + } else { + # Not Polymake::User::prefer_now: under --script + # $Polymake::User::application is a stub whose preferences are unset. + # Mode::create rather than the usual Mode::strict, so polymake does not + # consider its settings changed and rewrite them when a config path is in + # use. Once per application: in a persistent process, adding the same + # preference again makes polymake complain that one is already in effect. + my $app = $obj->type->application; + for my $expr (@$prefer) { + next if $polymaking_applied{$app->name}{$expr}++; + $app->prefs->add_preference($expr, Polymake::Core::Preference::Mode::create); + } + + for my $kw (@keywords) { + my $v = eval { my $x = $obj; $x = $x->$_ for split /\./, $kw; $x }; + if ($@) { + $r{errors}{$kw} = "$@"; + } elsif (!defined($v)) { + $r{errors}{$kw} = "undefined"; + } else { + my $s = eval { Polymake::Core::Serializer::serialize($v) }; + $@ ? ($r{errors}{$kw} = "$@") : ($r{values}{$kw} = $s); + } } } } + + open(my $fh, '>', $out) or die "pm.pl: cannot write $out: $!\n"; + print $fh Polymake::encode_json(\%r); + close($fh); + return 1; +} + +if (@ARGV) { + my ($errfile, $quiet, @prefer); + while (@ARGV && $ARGV[0] =~ /^--/ && $ARGV[0] ne '--version') { + my $opt = shift(@ARGV); + last if $opt eq '--'; + if ($opt eq '--stderr') { $errfile = shift(@ARGV) } + elsif ($opt eq '--quiet') { $quiet = 1 } + elsif ($opt eq '--prefer') { push @prefer, shift(@ARGV) } + else { die "pm.pl: unknown option $opt\n" } + } + polymaking_setup($errfile, $quiet); + my $out = shift(@ARGV); + my $file = shift(@ARGV); + $file = undef if defined($file) && $file eq '--version'; + polymaking_eval($out, undef, $file, \@prefer, @ARGV); } -open(my $fh, '>', $out) or die "pm.pl: cannot write $out: $!\n"; -print $fh Polymake::encode_json(\%r); -close($fh); +1; diff --git a/lib/userpref.gi b/lib/userpref.gi index 15f24f0..b221693 100644 --- a/lib/userpref.gi +++ b/lib/userpref.gi @@ -21,6 +21,7 @@ BindGlobal("POLYMAKING_LEGACY_SET", # vanished, e.g. after restoring a workspace saved in an earlier session. BindGlobal("POLYMAKING_STATE", rec(tmpdir := fail, scratch := fail, + server := fail, serverSettings := fail, version := fail, versionChecked := false)); @@ -174,6 +175,9 @@ and are usually just noise, so they are turned off by default. Set this to Independently of this, everything polymake writes to standard error is shown at InfoPolymaking level 2, and is included in the error message and in POLYMAKE&uscore;LAST&uscore;FAIL&uscore;REASON when a call fails. +

+polymake reports a credit once per session rather than once per call, so with +PolymakePersistent set, which is the default, each is seen once. """], default := true, values := [ true, false ], @@ -211,3 +215,21 @@ Each entry is passed to polymake's prefer_now. This works even when default := [], check := x -> IsList(x) and ForAll(x, IsString) )); + + +DeclareUserPreference(rec( + package := "polymaking", + name := "PolymakePersistent", + description := [ +"""controls whether one polymake process serves the whole &GAP; session. + +Starting polymake costs the best part of a second, nearly all of it spent +loading the rules of an application, so keeping one process alive makes any +session that calls polymake more than once considerably faster. Set this to +false to start polymake afresh for every call, which is slower but keeps +each call fully independent. +"""], + default := true, + values := [ true, false ], + multi := false +)); diff --git a/tst/persistent.tst b/tst/persistent.tst new file mode 100644 index 0000000..18a2d11 --- /dev/null +++ b/tst/persistent.tst @@ -0,0 +1,68 @@ +gap> START_TEST("persistent.tst"); +gap> oldpersist := UserPreference("polymaking", "PolymakePersistent");; + +# the same answers either way +gap> results := [];; p := fail;; +gap> for persist in [true, false] do +> SetUserPreference("polymaking", "PolymakePersistent", persist); +> POLYMAKING_StopServer(); +> p := CreatePolymakeObject();; +> AppendPointlistToPolymakeObject(p, [[1/4,1/75],[1/37,1/62],[1/91,1/24],[1/3,1/30]]); +> Add(results, List(["N_VERTICES","VOLUME","VERTICES","FACETS","BOUNDED"], +> kw -> Polymake(p, kw))); +> od; +gap> results[1] = results[2]; +true + +# rule preferences reach polymake in both modes. polymake compiles the helper +# script differently under --script than when the persistent process reads it, +# so this needs exercising both ways. +gap> oldprefs := UserPreference("polymaking", "PolymakePreferences");; +gap> SetUserPreference("polymaking", "PolymakePreferences", +> ["*.convex_hull beneath_beyond"]);; +gap> prefres := [];; +gap> for persist in [true, false] do +> SetUserPreference("polymaking", "PolymakePersistent", persist); +> POLYMAKING_StopServer(); +> Add(prefres, Polymake(p, "N_VERTICES" : PolymakeNolookup)); +> od; +gap> prefres; +[ 4, 4 ] +gap> SetUserPreference("polymaking", "PolymakePreferences", oldprefs);; +gap> POLYMAKING_StopServer(); + +# a server is started on demand, and only when asked for +gap> SetUserPreference("polymaking", "PolymakePersistent", false);; +gap> POLYMAKING_StopServer(); +gap> q := CreatePolymakeObject();; +gap> AppendPointlistToPolymakeObject(q, [[0,0],[1,0],[0,1]]); +gap> Polymake(q, "N_VERTICES"); +3 +gap> POLYMAKING_STATE.server = fail; +true +gap> SetUserPreference("polymaking", "PolymakePersistent", true);; +gap> Polymake(q, "VOLUME" : PolymakeNolookup); +1/2 +gap> POLYMAKING_STATE.server = fail; +false + +# a polymake that has gone away is replaced rather than reported +gap> CloseStream(POLYMAKING_STATE.server); +gap> Polymake(q, "N_VERTICES" : PolymakeNolookup); +3 +gap> POLYMAKING_STATE.server = fail; +false + +# strings reaching perl are quoted, whatever they contain +gap> POLYMAKING_PerlString("a\"b\\c$d@e"); +"\"a\\\"b\\\\c\\$d\\@e\"" +gap> POLYMAKING_PerlList(["x", "y"]); +"[\"x\",\"y\"]" +gap> POLYMAKING_Bool(true); +"1" +gap> POLYMAKING_Bool(false); +"0" + +# +gap> SetUserPreference("polymaking", "PolymakePersistent", oldpersist);; +gap> STOP_TEST("persistent.tst", 1);