Zulip Chat Archive
Stream: lean4
Topic: modifiable state inside a ReaderM struct
Chris Lovett (Sep 26 2022 at 21:44):
Wow, I just discovered you can do this:
structure Jobs where
processed : IO.Ref (List String)
def process (name : String): ReaderT Jobs IO Unit := do
let jobs ← read
jobs.processed.modify (λ s => name :: s)
def test (jobs : List String) : ReaderT Jobs IO Unit := do
for job in jobs do
process job
def main (args : List String): IO Unit := do
let processedRef ← IO.mkRef ([])
let jobs : Jobs := {processed := processedRef}
test args |>.run jobs
let result ← jobs.processed.get
IO.println result
#eval main ["apple", "banana", "orange"] --[orange, banana, apple]
Looking at the comments on lean_st_ref_get
it appears this is also thread safe which is cool. I assume this should be documented in Monads somewhere? I tried to rewrite my sample above using the lower level ST.Ref
primitives but got stuck. Does anyone know how to do that?
Mario Carneiro (Sep 26 2022 at 22:00):
A similar trick you may or may not be aware of is the StateRefT
monad transformer, which has the appearance and semantics of StateT
but is actually a ReaderT (ST.Ref _)
under the hood
Mario Carneiro (Sep 26 2022 at 22:02):
It's generally preferred whenever you are doing mostly linear updates to a data structure, since the use of mutable references makes it easier to ensure that you are doing efficient destructive updates and you aren't persisting the old state (unless you want to)
Mario Carneiro (Sep 26 2022 at 22:05):
Here's a version of your program in ST:
structure Jobs (ω) where
processed : ST.Ref ω (List String)
def process (name : String) : ReaderT (Jobs ω) (ST ω) Unit := do
let jobs ← read
jobs.processed.modify (λ s => name :: s)
def test (jobs : List String) : ReaderT (Jobs ω) (ST ω) Unit := do
for job in jobs do
process job
def foo (args : List String) : List String :=
runST fun ω => do
let processedRef ← ST.mkRef ([])
let jobs : Jobs ω := {processed := processedRef}
test args |>.run jobs
let result ← jobs.processed.get
pure result
#eval foo ["apple", "banana", "orange"] --[orange, banana, apple]
Chris Lovett (Sep 26 2022 at 22:11):
Cool, thanks, also unfortunately "goto definition" doesn't work on StateRefT, and find all returns a very long list, do you happen to know where it is defined?
Mario Carneiro (Sep 26 2022 at 22:18):
StateRefT
is actually a macro made to look like a regular definition, in order to hide the infectious ω
type argument. The actual definition is StateRefT'
Mario Carneiro (Sep 26 2022 at 22:24):
Here's a version using StateRefT
:
def process (name : String) : StateRefT (List String) (ST ω) Unit := do
modify fun s => name :: s
def test (jobs : List String) : StateRefT (List String) (ST ω) Unit := do
for job in jobs do
process job
def foo (args : List String) : List String :=
runST fun _ => do
let (_, result) ← test args |>.run ([])
pure result
#eval foo ["apple", "banana", "orange"] --[orange, banana, apple]
Chris Lovett (Sep 26 2022 at 22:27):
ah, but in that StateRefT version you have a direct "modify" method so it's looking more like a StateM monad at that point, the previous one is more interesting to me because the functions are all ReaderT, but one of them reaches in and modifies an ST.Ref structure field... which almost seems to break the promise that ReaderT is readonly...
Mario Carneiro (Sep 26 2022 at 22:28):
The way you should think about it is that an IO.Ref
is like a pointer, and the pointer doesn't change even though the data behind it does. So the Ref
can be a regular immutable value stored in a function parameter or in the reader context, and all the mutation goes via the impure IO.Ref.get
and IO.Ref.set
functions. ST ω
is just a way of doing the same thing as IO
but with a "local heap" identified by ω
, and with a safe runST
function for running an ST
computation in pure code.
Chris Lovett (Sep 26 2022 at 23:13):
last question, what is ST
short for in namespace ST ? Can you write paragraph that motivates ST ? Specifically, how is it different from breaking your context into 2 structs so you can use ReaderT ReadOnlyContext (StateT ReadWriteContext) ...
? Is this ST.Ref in a readonly context achieving something you can't do that way? For example, the Lean LSP server uses one of these for the RBMap that stores FileWorker objects, in the ServerContext where the ServerM monad is ReaderT ServerContext IO
. Could this have been done using ReaderT ServerContext (StateT FileWorkerMap) ...
? So is ST just a shortcut for lazy people? :-) Or does this somehow also make updating the RBMap more efficient somehow?
Mario Carneiro (Sep 26 2022 at 23:23):
By the way, in case you were missing the context, ST
is an innovation from haskell. If you search for runST
online you will probably find some discussions about it
Mario Carneiro (Sep 26 2022 at 23:24):
The whole design and most of the names are lifted straight from there
Mario Carneiro (Sep 26 2022 at 23:24):
ST is short for "state" I guess
Mario Carneiro (Sep 26 2022 at 23:30):
I'm not even sure it's all that great a fit for lean. The trick with using type variables for "branding" local heaps is foreign to lean (and not even relevant since the compiler doesn't take advantage of it), and the things ST
does are pretty close to provably impossible within the lean logic
Chris Lovett (Sep 26 2022 at 23:37):
Oh, https://wiki.haskell.org/Monad/ST - it is "strict Sate Threads", so there is a real connection to multithreading... which leads to https://www.microsoft.com/en-us/research/publication/lazy-functional-state-threads/ yummy.
Mario Carneiro (Sep 26 2022 at 23:40):
I guess the idea is that ST values can be mutated thread-unsafely because the data is constrained to live on one thread?
Mario Carneiro (Sep 26 2022 at 23:40):
I can't imagine that really working out in lean
Mario Carneiro (Sep 26 2022 at 23:45):
oh, maybe it's short for Single Threaded
Chris Lovett (Sep 26 2022 at 23:45):
It looks like lean_st_ref_get is trying to do some thread safe stuff...
Mario Carneiro (Sep 26 2022 at 23:46):
yeah, in lean they are definitely just the same thing as IO refs
Mario Carneiro (Sep 26 2022 at 23:48):
in fact, IO.Ref
is just a wrapper around ST.Ref
, ST.Ref.get
and IO.Ref.get
are the same function
Chris Lovett (Sep 27 2022 at 00:44):
Trying to run each "process" job in a separate Task, but I get back an empty processed list, what am I doing wrong? (I wish we had tasks.waitAll)
structure Jobs (ω) where
processed : ST.Ref ω (List String)
def process (name : String) : ReaderT (Jobs ω) (ST ω) Unit := do
let jobs ← read
jobs.processed.modify (λ s => name :: s)
def processTasks (args: List String) : ReaderT (Jobs ω) (ST ω) Unit := do
let jobs ← read
let tasks := args.map (λ s => Task.spawn fun _ => dbgSleep 1000 fun _ => process s |>.run jobs)
let result := tasks.map (λ s => s.get)
return
def foo (args : List String) : List String :=
runST fun ω => do
let processedRef ← ST.mkRef ([])
let jobs : Jobs ω := {processed := processedRef}
processTasks args |>.run jobs
let result ← jobs.processed.get
return result
#eval foo ["apple", "banana", "orange"] --[] ???
Chris Lovett (Sep 27 2022 at 00:46):
and is there an easier way? Does lean have a parallel.foreach ?
Mario Carneiro (Sep 27 2022 at 01:01):
You never ran the ST actions
Mario Carneiro (Sep 27 2022 at 01:01):
Are you getting an unused variable result on the let result :=
line? It should have been a red flag
Chris Lovett (Sep 27 2022 at 01:03):
So process s |>.run jobs
doesn't run it?
Mario Carneiro (Sep 27 2022 at 01:04):
What you did in processTasks is to create a List (Task (ST ω Unit))
consisting of tasks that will resolve to an ST action after 1 second, then mapping Task.get
on them to get a List (ST ω Unit)
(note that at this point none of these ST actions have run yet). The list is then dropped, and lean being a functional language sees that all the work is pointless and (correctly) optimizes the whole function to pure ()
Mario Carneiro (Sep 27 2022 at 01:05):
It runs the ReaderT
wrapper, which means that it accepts the Jobs
reference and resolves to an action in the base monad, here ST
Mario Carneiro (Sep 27 2022 at 01:05):
you still have to run the ST
action
Mario Carneiro (Sep 27 2022 at 01:06):
And you can't, this is the whole point of the "single threaded" business for the ST monad
Mario Carneiro (Sep 27 2022 at 01:06):
You would have to be in the original runST
invocation (that is, the main thread) in order to run it
Mario Carneiro (Sep 27 2022 at 01:07):
inside your task you are computing a pure function so you don't have access to any state to mutate
Mario Carneiro (Sep 27 2022 at 01:08):
What you actually want is to create an impure task, using the confusingly-located BaseIO.asTask
function
Mario Carneiro (Sep 27 2022 at 01:08):
and you need to use IO refs instead of ST refs and do everything in IO
Mario Carneiro (Sep 27 2022 at 01:10):
this is obviously not a pure computation since you are deliberately trying to race threads to get the values out in a random order, which is not allowed for pure functions
Mario Carneiro (Sep 27 2022 at 01:15):
Here's the corrected version:
structure Jobs where
processed : IO.Ref (List String)
def process (name : String) : ReaderT Jobs IO Unit := do
let jobs ← read
jobs.processed.modify (λ s => name :: s)
def processTasks (args: List String) : ReaderT Jobs IO Unit := do
let jobs ← read
let tasks ← args.mapM fun s => IO.asTask do
IO.sleep 1000
process s |>.run jobs
for task in tasks do
ofExcept task.get
def foo (args : List String) : IO (List String) := do
let processedRef ← IO.mkRef ([])
let jobs : Jobs := {processed := processedRef}
processTasks args |>.run jobs
jobs.processed.get
#eval foo ["apple", "banana", "orange"] --["banana", "orange", "apple"]
--["banana", "apple", "orange"]
--["orange", "banana", "apple"]
Mario Carneiro (Sep 27 2022 at 01:18):
one thing to note about this example: it is important that we "use" the result of the tasks, here by matching on all of them with ofExcept
, because if we don't the compiler is still permitted to drop the threads early or never start them
Chris Lovett (Sep 27 2022 at 01:19):
Thanks, I guess this means the processed.modify
is actually thread safe, and ST is not about "thread local storage"...
Mario Carneiro (Sep 27 2022 at 01:19):
well note that it's not ST any more
Mario Carneiro (Sep 27 2022 at 01:20):
There isn't an ST version of this example
Mario Carneiro (Sep 27 2022 at 01:21):
because there is no ST.asTask
Mario Carneiro (Sep 27 2022 at 01:22):
but yes, IO refs are thread safe
Mario Carneiro (Sep 27 2022 at 01:22):
(and ST refs as well, since they are currently not distinguished from IO refs, although haskell makes a distinction and maybe lean will in the future)
Mario Carneiro (Sep 27 2022 at 01:24):
Just about everything in lean is thread safe. It's pretty hard to guarantee that some data is not concurrently accessible except the refcount = 1 trick lean uses for destructive update
Chris Lovett (Sep 27 2022 at 01:25):
Very instructional, thanks! we should also write a chapter in the reference manual about Tasks. This chapter is just a bit too cryptic :-). Is there a "parallel.foreach" in lean that will do task creation for each item in the enumeration and gathering of results for me?
Mario Carneiro (Sep 27 2022 at 01:26):
that's the .map Task.get
trick from before
Mario Carneiro (Sep 27 2022 at 01:27):
It wouldn't be too hard to make a macro that does it, I think
Chris Lovett (Sep 27 2022 at 01:36):
Yea, I'd want processTasks to collapse down to a single line like Parallel.foreach args (λ _ => process s |>.run jobs)
and this would return the gathered results (or throw). I realize the results in this case are not interesting, but assuming we were not playing with IO.ref state in this example and process returned a String, then parallel.foreach would return List String. See the F# version, they call it Async.Parallel
...
Do we have a naming convention for "async" methods that return a Task? Like in this version?
structure Jobs where
processed : IO.Ref (List String)
def asyncProcess (name : String) : ReaderT Jobs IO (Task (Except IO.Error Unit)) := do
let jobs ← read
IO.asTask do
IO.sleep 500
jobs.processed.modify (λ s => name :: s)
def processTasks (args: List String) : ReaderT Jobs IO Unit := do
let jobs ← read
let tasks ← args.mapM fun s => asyncProcess s |>.run jobs
for task in tasks do
ofExcept task.get
def foo (args : List String) : IO (List String) := do
let processedRef ← IO.mkRef []
let jobs : Jobs := {processed := processedRef}
processTasks args |>.run jobs
jobs.processed.get
Mario Carneiro (Sep 27 2022 at 01:41):
Actually, I started looking at this but I don't think it makes sense. What monad would it live in? What would you even be able to do in the body of the loop?
Mario Carneiro (Sep 27 2022 at 01:42):
For loops structurally don't return a value, but if you don't return a value from a task it's completely useless
Mario Carneiro (Sep 27 2022 at 01:43):
Maybe you should show something a little less like a toy example
Mario Carneiro (Sep 27 2022 at 01:57):
def parForIn [ForIn IO σ α] (xs : σ) (f : α → IO PUnit) : IO PUnit := do
let mut tasks := #[]
for x in xs do
tasks := tasks.push (← IO.asTask (f x))
tasks.forM (ofExcept ·.get)
syntax "parallel " "for " ident " in " termBeforeDo " do " doSeq : doElem
macro_rules
| `(doElem| parallel for $x in $xs do $seq) => `(doElem| parForIn $xs fun $x => do $seq)
structure Jobs where
processed : IO.Ref (List String)
def asyncProcess (name : String) : ReaderT Jobs IO (Task (Except IO.Error Unit)) := do
let jobs ← read
IO.asTask do
IO.sleep 500
jobs.processed.modify (λ s => name :: s)
def processTasks (args: List String) : ReaderT Jobs IO Unit := do
let jobs ← read
let tasks ← args.mapM fun s => asyncProcess s |>.run jobs
for task in tasks do
ofExcept task.get
def foo (args : List String) : IO (List String) := do
let processedRef ← IO.mkRef []
let jobs : Jobs := {processed := processedRef}
parallel for name in args do
IO.sleep 500
jobs.processed.modify (λ s => name :: s)
jobs.processed.get
#eval foo ["apple", "banana", "orange"] --["banana", "orange", "apple"]
Chris Lovett (Sep 27 2022 at 01:57):
Perhaps I don't understand your comment, but I found the parallel task library in .NET to be extremely useful, and powerful, since you can customize every aspect of task scheduling, prioritization and so on. And it works really well with LINQ so you each task can pull async data from SQL, or HTTP or files, and transform the data, which you can then gather up and send along to a client. Even the Lean LSP could use this and process all dependent files in a project when we add proper LSP project support. But if lean Monads make it hard to type the result of a parallel operation over monadic functions then I guess this is a big strike against Monads, so it would be good to figure out how to solve that...
Mario Carneiro (Sep 27 2022 at 01:59):
You need to learn to think more functionally about things. Most of these things are approached from a completely different angle. It's not that you can't do them, but rather putting them in imperative clothes isn't always a useful thing to do
Mario Carneiro (Sep 27 2022 at 01:59):
Lake is highly parallel and also highly functional, and the server is too
Chris Lovett (Sep 27 2022 at 02:00):
Ok, but I need to utilize all 24 cores on my CPU, how does Lean help me do that? Are you saying the right answer is IO.Process.spawn (like lake), and not Task.spawn?
Mario Carneiro (Sep 27 2022 at 02:01):
Does IO.asTask
not work?
Mario Carneiro (Sep 27 2022 at 02:01):
Or Task.spawn
for pure functions
Mario Carneiro (Sep 27 2022 at 02:02):
It's true that lean's support for threading primitives is pretty bare (see lean4#1280), but for this particular task Task
seems to be the right tool for the job
Chris Lovett (Sep 27 2022 at 02:03):
It is the minimum viable product yes. Just doesn't have all the bells and whistles I'm used to having.
Mario Carneiro (Sep 27 2022 at 02:04):
sure, but you haven't even asked for anything that needs the bells and whistles
Chris Lovett (Sep 27 2022 at 02:04):
When I saw AsyncList in the Lean LSP Server I'm like why isn't this easier to do and generally available outside the Server implementation?
Mario Carneiro (Sep 27 2022 at 02:05):
I'm a believer in doing something 3 times before making a premature abstraction for it
Mario Carneiro (Sep 27 2022 at 02:05):
every application has slightly different requirements, and you shouldn't make the library version until you have a reasonable grasp on what those requirements are
Chris Lovett (Sep 27 2022 at 02:06):
I like where https://github.com/leanprover/lean4/issues/1280 is going, I am also a huge fan of channels as a way to coordinate across tasks - I think a channel would make the FileWorker management MUCH easier.
Mario Carneiro (Sep 27 2022 at 02:07):
For instance, AsyncList
corresponds to a lazy list, in which the tail of the list can be delayed and in a thunk. That's different from your application above, where you want all the results to be run in parallel
Mario Carneiro (Sep 27 2022 at 02:08):
if AsyncList
was the library abstraction, it wouldn't be appropriate for your use case
Mario Carneiro (Sep 27 2022 at 02:08):
just because it's got Async
and List
in the name doesn't mean that tells the whole story
Chris Lovett (Sep 27 2022 at 02:08):
I agree it is very hard to design a really great framework. Often times frameworks just add more layers of "inefficiency" that add up to big performance problems. But Lean has some new things to offer in that regard with metaprogramming, one could think about a self assembling framework that provides only what a given user needs, and optimized for that usage scenario. Could be very interesting.
Mario Carneiro (Sep 27 2022 at 02:09):
Okay, but again you have to start with the task at hand
Mario Carneiro (Sep 27 2022 at 02:09):
and work backward to the best way to support that
Mario Carneiro (Sep 27 2022 at 02:10):
I don't think we have a very clear idea at all of what a library abstraction in this space would actually be, besides what Task
already is
Mario Carneiro (Sep 27 2022 at 02:10):
(Since you mentioned it: you can set thread priorities in Task
primitives BTW.)
Chris Lovett (Sep 27 2022 at 02:11):
There's a many very well designed parallel libraries to learn from including C++, .NET, Java, Rust, so I don't think you need to start from first principles and ask each user "what are you trying to do". If you approach it that way you won't have something great for another 10 years.
Mario Carneiro (Sep 27 2022 at 02:11):
also LINQ is just functional style programming for C#
Mario Carneiro (Sep 27 2022 at 02:13):
No you really do have to design it afresh. Most imperative designs don't work at all in functional languages, or need to be rephrased such that they would hardly be recognizable by the end
Mario Carneiro (Sep 27 2022 at 02:13):
You just named a bunch of imperative languages
Chris Lovett (Sep 27 2022 at 02:14):
Yes which is why I love it so much :-) I was one the incubation team that developed X#
which lead to c-omega which was the precursor to LINQ. We showed the C# team how to do it :-)
Chris Lovett (Sep 27 2022 at 02:14):
Well F# seems to have bolted onto .NET parallel library somehow... but I've never used it so I don't know how good it is.
Mario Carneiro (Sep 27 2022 at 02:14):
anyway this doesn't seem to be an actually actionable suggestion. Do you want to suggest something more concrete?
Mario Carneiro (Sep 27 2022 at 02:15):
Or better yet, #xy the problem since the tools to do what you want are already available, once you figure them out
Chris Lovett (Sep 27 2022 at 02:27):
Nope just a question for now, and I like your "parallel for" macro. Thanks for all your help. Getting back to the question about ST
I'll assume it means single threaded, and that when we used it in the parallel test, the modify method worked reliably because it was protecting the update to the ST.Ref List String
ensuring that each update was serialized...
Chris Lovett (Sep 27 2022 at 02:45):
Here's a better test and it passes as expected:
structure Jobs where
processed : IO.Ref (List Nat)
def asyncProcess (name : Nat) : ReaderT Jobs IO (Task (Except IO.Error Unit)) := do
let jobs ← read
IO.asTask do
IO.sleep 1
jobs.processed.modify (λ s => name :: s)
def processTasks (args: List Nat) : ReaderT Jobs IO Unit := do
let jobs ← read
let tasks ← args.mapM fun s => asyncProcess s |>.run jobs
for task in tasks do
ofExcept task.get
def foo (args : List Nat) : IO (List Nat) := do
let processedRef ← IO.mkRef []
let jobs : Jobs := {processed := processedRef}
processTasks args |>.run jobs
jobs.processed.get
def test : IO Unit := do
let max := 10000
let x ← foo (List.range max)
let a := x.toArray.qsort (·<·)
for expected in List.range max do
let actual := a[expected]!
if actual ≠ expected then
IO.println s!"unexpected {actual} instead of {expected}"
IO.println "ok"
#eval test
Chris Lovett (Sep 27 2022 at 03:01):
But getting back to my original question, how is this IO.Ref any better than using StateT like this? I assume it is simply that we don't have to create a new structure Jobs
container each time we update the state? So is that all it is really for then, this minor efficiency improvement? If the StateT was on List Nat
then even that difference goes away? But perhaps if we have a sparse collection of randomly updatable objects then an ST.Ref for each one of those would be a win...? Am I missing something?
structure Jobs where
processed : List Nat
def asyncProcess (i : Nat) : StateT Jobs IO (Task (Except IO.Error Unit)) := do
modify (λ s => { processed := i :: s.processed })
IO.asTask do
IO.sleep 1
def processTasks (args: List Nat) : StateT Jobs IO Unit := do
let tasks ← args.mapM fun s => asyncProcess s
for task in tasks do
ofExcept task.get
def foo (args : List Nat) : IO (List Nat) := do
let jobs : Jobs := {processed := []}
let (_, s) ← processTasks args |>.run jobs
return s.processed
def test : IO Unit := do
let max := 10000
let x ← foo (List.range max)
let a := x.toArray.qsort (·<·)
if a.size ≠ max then
IO.println s!"Returned wrong length {a.size} instead of {max}"
else
for expected in List.range max do
let actual := a[expected]!
if actual ≠ expected then
IO.println s!"unexpected {actual} instead of {expected}"
IO.println "ok"
#eval timeit "running: " test -- ok, running: 503ms
StateT does this in 504ms, and the previous ST.Ref version did it in 509ms, so essentially the same... probably a huge thread start/join overhead in this case...
Chris Lovett (Sep 27 2022 at 03:15):
Ah, but I can't move the modify (λ s => { processed := i :: s.processed })
inside the IO.asTask
so perhaps THAT is the reason for ST.Ref...
Mario Carneiro (Sep 27 2022 at 03:24):
Regarding your test, here's a simplification which does a lot of contentious writes to a shared variable, where the modification function is slow.
#eval show IO _ from do
let var ← IO.mkRef 0
let tasks ← (List.range 50).mapM fun _ => IO.asTask do
for _ in [0:50] do
let f i := dbgSleep 1 (fun _ => i+1)
var.modify f
tasks.forM (ofExcept ·.get)
var.get
#eval show IO _ from do
let var ← IO.mkRef 0
let tasks ← (List.range 50).mapM fun _ => IO.asTask do
for _ in [0:50] do
let f i := dbgSleep 1 (fun _ => i+1)
var.set (f (← var.get))
tasks.forM (ofExcept ·.get)
var.get
The first test is fairly slow and also gets the right answer (50*50 = 2500
), while the second test is fast and also wrong (I get almost exactly 400 = 50*8
which again reveals the concurrency). This is demonstrating something that probably should have been in the docs: Ref.modify
atomically performs the modification by taking a lock on the ref, so all the writes are serialized. In the second example we use get
and set
separately and lots of increments are lost due to concurrent modification.
Chris Lovett (Sep 27 2022 at 03:29):
yes separate get and set is rife with problems. One needs an atomic update, sounds like modify is exactly that. Note that in Simon's paper they say that "ST" stands for "State Transformer".
Mario Carneiro (Sep 27 2022 at 03:31):
Chris Lovett said:
But getting back to my original question, how is this IO.Ref any better than using StateT like this? I assume it is simply that we don't have to create a new
structure Jobs
container each time we update the state? So is that all it is really for then, this minor efficiency improvement? If the StateT was onList Nat
then even that difference goes away? But perhaps if we have a sparse collection of randomly updatable objects then an ST.Ref for each one of those would be a win...? Am I missing something?
The differences are washed out even more than you might expect because lean performs destructive updates when you use a variable linearly (that is, you don't keep multiple references to the state, which could require persisting old values). So in many tests like this you will see almost zero difference between using mutation and using StateT
which does functional-but-in-place mutation of a value.
However, the FBIP optimization is brittle and also comes with some overheads of its own, depending on how you write it. For example StateM s a
is a wrapper for a function s -> a × s
, which means that every call to a StateM
function has to allocate a pair and then destruct it. The compiler can often optimize these away but it's not really zero cost. With "true mutation" you don't have to put the s
in an extra box (although there is a very similar overhead to this in using IO
because of the error checking, although there is work being done on the compiler to make IO zero cost in more cases).
It's not a clear win in either direction. Choose the form that fits your design the best.
Mario Carneiro (Sep 27 2022 at 03:32):
Chris Lovett said:
yes separate get and set is rife with problems. One needs an atomic update, sounds like modify is exactly that. Note that in Simon's paper they say that "ST" stands for "State Transformer".
yikes, that makes it a bit of a nightmare to disambiguate ST
and StateT
Chris Lovett (Sep 27 2022 at 03:35):
Yeah, a bit of a collision in terminology with "StateT" being a "Monad Transfomer with updatatable State" and "ST" being an "State Transformer Monad"...
Chris Lovett (Sep 27 2022 at 04:13):
And yes, I did notice that ExceptM
adds an Except wrapper even in the Except.ok
code path, which seems not so great to me. Most other runtimes work very hard to ensure you only pay for exception handling except in the exceptional cases where someone actually calls throw
. So yes, it would be very cool if we could optimize that way somehow.
Mario Carneiro (Sep 27 2022 at 04:49):
Lean's Except
is more similar to Rust's Result
type or C error return values than traditional exception handling in the style of C++ / C# / Java which use stack unwinding and runtime type information (RTTI). That strategy has very low cost on the happy path but is super expensive if you ever use it, and many C/C++ codebases avoid it entirely.
Mario Carneiro (Sep 27 2022 at 04:53):
That kind of side channel is also a really poor fit for functional languages generally. The nearest equivalent is the CPS monad, where you pass an error continuation to be called in the event of an exception. It works pretty well but it also incurs the cost of a closure allocation in the common case.
Chris Lovett (Sep 27 2022 at 05:33):
Totally agree stack unwinding is a horrible thing, even in languages that have implemented it really well. So yeah, no free lunch without it. I guess so long as Except
is on the stack instead of on the heap that would help a lot.
Chris Lovett (Sep 27 2022 at 05:33):
So I put a doc together, https://github.com/leanprover/lean4/pull/1653, waddaya think?
Mario Carneiro (Sep 27 2022 at 17:11):
@Chris Lovett Added some review comments. There is another thing about ST that I did not mention, but is a pretty important difference from StateT
: The ST
monad allows you to create references of arbitrary types, you don't have to determine all the state you are going to carry in advance at the call to StateT.run
. You can see this in the type of ST
: although it has this mysterious ω
parameter, what it doesn't have is the state type like StateT
would.
Mario Carneiro (Sep 27 2022 at 17:11):
def foo (ref : ST.Ref ω Nat) (bar : ST ω Unit) : ST ω (Nat × Nat) := do
let x ← ref.get
bar
return (x, ← ref.get)
#eval runST fun ω => do
let x : ST.Ref ω Nat ← ST.mkRef 0
let y : ST.Ref ω (List Nat) ← ST.mkRef []
let z ← foo x do
x.modify (·+1)
y.modify (3::·)
return (← x.get, ← y.get, z) -- (1, [3], 0, 1)
Mario Carneiro (Sep 27 2022 at 17:14):
Here's an example that highlights some of this. The foo
function reads a Nat
reference, then runs an arbitrary function bar
, then reads the reference again. It has no idea how much state is actually in the monad, whether there are any other references and what types they are. In the main function, we call foo
where there are two references, one of type Nat
and one of type List Nat
, and modify both of them in bar
, so foo
can see the state change to x
but the change to y
also happened. You can't do this sort of thing using StateT
without a lot of contortion.
Chris Lovett (Sep 27 2022 at 20:55):
Interesting, looking at your doc feedback, where you want to simplify everything and remove all mention of ReaderM and so on seems like it no longer fits in the monads chapter. So I'm wondering if we need a separate page someplace that talks about "references" independently from monads (I think your simpler example that has nothing to do with monads really, except that it is running in an IO monad). Then perhaps my mentioning of ST.Ref in the Monads chapter could happen in ReaderM just as a side note, and we could put another mention in the documentation on let mut
because an IO.Ref also gets around the limitations of let mut in a multithreading app. But the above information then makes me think we do still need an ST monad specific page in the monads chapter that contains this kind of info... more on "ST" properties so it is not just about "ST.Ref"?
Mario Carneiro (Sep 27 2022 at 20:58):
I would start by presenting the type signatures of the ST.Ref
functions and runST
, then explain runST
a bit because it's weird, then give a motivating example and contrast with StateT. It's certainly most relevant to the monads chapter because it's a specific tool in monadic programming
Mario Carneiro (Sep 27 2022 at 21:00):
It should be put in a place where we can have some reasonable expectation that the reader can understand how monad type signatures work and what do
notation does at least at a basic level, meaning it has to come in or after the monads chapter
Mario Carneiro (Sep 27 2022 at 21:00):
it's a more advanced topic to be sure though
Chris Lovett (Sep 27 2022 at 23:11):
Ok, I think I will move the "ST" page so it comes after "Transformers" then I can also show StateRefT, and call it more of an advanced topic and I will start by presenting ST, ST.Ref and runST in a simpler example and I will rename it "ST References" since ST and ST.Ref are inseparable.
But I have a quick question, when I look at https://github.com/leanprover/lean4/blob/master/src/Lean/Meta/Tactic/Cleanup.lean it uses StateRefT
in a bunch of local functions and if I change all those to StateT
it all compiles and works? So why is StateRefT
used here instead of StateT
?
Mario Carneiro (Sep 27 2022 at 23:18):
StateRefT
is basically a wrapper around ST
made to give it the interface of StateT
Mario Carneiro (Sep 27 2022 at 23:19):
That is, it is deliberately designed to be a drop in replacement for StateT
Chris Lovett (Sep 27 2022 at 23:22):
Right, which explains why I could drop in StateT and everything worked, but my question is why did the author choose StateRefT in this case instead of StateT ? Is there some functional difference that is important in this case?
Mario Carneiro (Sep 27 2022 at 23:22):
Well you should check the benchmark numbers for that
Mario Carneiro (Sep 27 2022 at 23:23):
There is no functional difference, but there could be a performance difference
Chris Lovett (Sep 27 2022 at 23:23):
Got it, is there a benchmark test for Lean.Meta already? Where do I find it and learn how to run it?
Mario Carneiro (Sep 27 2022 at 23:27):
http://speedcenter.informatik.kit.edu/velcom/home
Chris Lovett (Sep 27 2022 at 23:41):
Thanks, I just found the connection in ~/tests/bench/...
Chris Lovett (Sep 27 2022 at 23:59):
Any ideas on this error related to Lean.Data.HashMap ?
image.png
Chris Lovett (Sep 27 2022 at 23:59):
my branch is up to date with lean4 master...
Mario Carneiro (Sep 28 2022 at 00:01):
it looks like you are using nightly lean in the error message
Mario Carneiro (Sep 28 2022 at 00:06):
try elan override set lean4
in the directory, assuming you previously set elan toolchain link lean4 <path/to/stage1>
Chris Lovett (Sep 28 2022 at 00:11):
I see, yes elan override set ...
solved the problem. That's not mentioned in the readme.
Chris Lovett (Sep 28 2022 at 00:12):
looks like another missing dependency also not mentioned in the readme (scipy is also missing):
Benchmark 1 out of 10 to 10 [------------------------------------] 0%[17:11:44] Program block no. 0 failed: The perf tool needed for the perf stat runner isn't installed. You can install it via the linux-tools (or so) package of your distribution. If it's installed, you might by only allowed to use it with super user rights. Test a simple command like
perf stat /bin/echo
to see what you have to do if you want to use with your current rights.
Doyou know the magic install command line for this?
Chris Lovett (Sep 28 2022 at 00:14):
Ubuntu says to try sudo apt install linux-tools-common
so I'll try it...
Mario Carneiro (Sep 28 2022 at 00:15):
I have never used the benchmarking tool
Chris Lovett (Sep 28 2022 at 00:36):
I see, well to conclude the question then I just discovered that the linux perf tool is not supported in WSL. So that's my problem.
Last updated: Dec 20 2023 at 11:08 UTC