Pure Borrow: Linear Haskell Meets Rust-Style Borrowing

TL;DR You can now use Rust-style mutable and shared borrows in Linear Haskell, within ST-like linear monad, BO, with pure, type-safe, and leak-freedom support of mutation and concurrency! It comes with flexible feature of multiple aliasing of shared borrows, delimiting lifetime regions, etc!

We are really pleased to announce the acceptance of our work, Pure Borrow, at PLDI 2026 :tada:

This is joint work of Yusuke Matsushita, the first author of “RustHorn: CHC-based Verification for Rust Programs”, and I.
I am mainly in charge of the implementation and Yusuke-san does the theoretical works.

Background

Linear Haskell, i.e. today’s GHC with LinearTypes extension enabled, introduces the new arrow type a %1 -> b, which reads "If the application is consumed exactly once, then so is the argument of type a.
In particular, it ensures that there is exactly one owner of the resource bound by the function, and this allows us fine-grained resource management and do destructive updates purely.
The guarantee of the uniqueness of the owner, or absence of alias, typically should allow a pure and deterministic parallel computation.
This intuition once lead me to the exploration around the pure and parallel divide-and-conquer algorithm on arrays:

Meanwhile, Yusuke started Pure Borrow project and just stumbled upon my article above.
He reached out to me and we started to work together :slight_smile:

What’s New in Pure Borrow?

In Pure Borrow, we bring the notion of Rust-style borrows into the Linear Haskell realm.

To see the flavour, we give a simple parallel quicksort implementation in Pure Borrow:

BO α is the monad that plays the central role in Pure Borrow.
It is a linear-variant of the ST-monad, with each parameter α corresponds to the lifetime where the resources are borrowed for. It can also be viewed as a linear generalization of monadic regions, but for pure computation.

Note that parIf is a simple combinator to run the given two computations in parallel with parBO primtive when the condition is met, and runs sequentially otherwise.
This is safe and pure, thanks to Pure Borrow’s support of disjoint division of borrows.

Mut α x is the mutable borrow of a resource of type x - in particular, it is always bound and consumed linearly.
Indeed, it is a special case of more general ‘Borrow’ type:

There is one more borrow type: Share:

So, what’s the difference between mutable and shared borrows?
First, both borrows a resource from its unique Lender:

A Mut α a retains the exclusive permission to mutate the borrowed resource of type a during the lifetime α.
Together with Lend α a, it can be introduced by borrowing a bare resource:

Note that they are bound linearly, making sure there is only one Mut α.

On the other hand, Share α a can be aliased nonrestrictedly, i.e. they can be bound for any number of times, in the absence of Mut α a.
The primal introduction rule of Share α a is shareing it:

Note that Ur a is defined as follows, which means that value wrapped inside Ur can be used for as many times as possible (i.e. nonlinearly) after pattern matching on it:

data Ur a where Ur :: a %'Many -> Ur a

As share consumes Mut α a linearly, it assumes there is no Mut α a left after the sharing.
In this way, Pure Borrow allows both localized pure destructive updates and multiply-aliased shared read access to the resources.

Further, both types of borrow can be divided into pieces.
One such example is splitAt function used in the quicksort example above:

splitAt ::
  Int %1 ->
  Borrow bk α (Vector a) %1 -> 
  (Borrow bk α (Vector a), Borrow bk α (Vector a))

Some immutable types (e.g. primitive types such as Int or Bool, and the recursive type with those elements like Maybe Char or [Either Int (Bool, String)], etc.) can be copied into the direct value:

We also have a variant for random access to arrays:

Which is implemented in terms of get on an array and copy:

But, both copyAt and copy are expecting Share α a as its argument.
How can we apply it to Mut α a things?
Here, the concept of sublifetime comes into a play, as in the implementation of copyAtMut:

Where, sharing combinator delimits the ephemeral sublifetime and share the given mutable borrow:

It is implemented in terms of share and reborrow (and reborrow in terms of more low-level primitives):

Aside from the quicksort example, here is a simple example demonstrating a mixed use of mutable and shared borrows:

example :: (Int, Int, Int, [Int])
example = linearly \lin -> DataFlow.do
  (lin, lin') <- dup lin
  vec <- fromList [0, 1, 2] lin
  runBO lin' \ @α -> Control.do
    let !(mvec, lend) = borrowLinearOnly vec
    mvec <- modify 0 (+ 3) mvec
    ((i0, i2), mvec) <- sharing mvec \svec -> Control.do -- (*)
      Ur i0 <- copyAt 0 svec
      Ur i2 <- copyAt 2 svec
      Control.pure (i0, i2)
    mvec <- modify 2 (+ 5) mvec -- (a)
    mvec <- modify 0 (* 4) mvec -- (b)
    let !(Ur svec) = share mvec
    Ur n <- copyAt 0 svec
    pureAfter (i0, i2, n, unur $ toList (reclaim @α lend))

This just allocates a mutable array with elements [0, 1, 2], multiply the first element, inspect first and third intermediate values, modify the third and first again, then retrieve the final value of the first value.
This evaluates to (3, 2, 12, [12, 1, 7]) as expected.

:warning: Use GHC 9.12.3 or higher when using REPL (EDITED)
If you want to use REPL to evaluate the above code, please use GHC 9.12.3 - GHC 9.10.3 and 9.12.2 contains a bug that results in segfaults.
(Once we considered the behaviour is our correctness bug, but @romes kindly pointing it out it was the bug in GHCi <9.12.2 and he fixed it in 9.12.3 onward. Thank you @romes!)

The sharing in the line (*) delimits the sublifetime, and share the contents of mvec temporarily for that lifetime.
Further note that, although we thread through mutable share mvec manually (i.e. pass and given back by each function call on mvec), we don’t have to thread through shared borrow svecs and can call multiple times and read it for multiple times.
After the sublifetime ends, the original mvec is returned and the process proceeds.
Note that svec is bound nonlinearly, we do not need to return the value or consuming it explicitly.

We can go further. Now that mutation in (a) and (b) are on the disjoint parts, we can do it parallelly, by replacing lines (a) and (b) as follows:

    mvec <- reborrowing_ mvec \mvec -> Control.do
      let !(mvec1, mvec2) = LV.splitAt 2 mvec
      consume Control.<$> 
        LV.modify 0 (+ 5) mvec2 `parBO` LV.modify 0 (* 4) mvec1

This time, the mutable borrow mvec is reborrowed into the sublifetime, then divided into two pieces.
We then modify each pieces in parallel using parBO combinator, and discard the returned mutable borrows by calling consume combinator on the returned value.
In this way, we can discard the mutable borrows amidst in the sublifetime without worrying about leaking.

Shout out for Linear Constraints

Our API makes extensive use of many permission tokens; we need Linearly token to ensure the safety of allocation, End α token to run the finalization after the lifetime ends, and, even though it doesn’t manifest in the high-level APIs, we need the unique Now α linear token for each lifetime α to ensure the uniqueness of the lifetime.
Manually threading them is somewhat cumbersome - and we are really missing for Linear Constraints to ease such handle token management!

Indeed, the permission tokens cover all three types of (non-)linear constraints being proposed:

  1. End α is a (nonlinear) constraint, which can just be expressed as an ordinary typeclass.
  2. Linearly is a duplicable linear constraint, which is one of the motivating examples of original Linear Constraints.
  3. Now α is a non-duplicable linear constraint.

Indeed, End α is already made a typeclass in our actual implementation (we didn’t do so in the paper to avoid complication).
Then, why not for Linearly and Now α? In this sense, we think our Pure Borrow can be yet another motivating application of Linear Constraints.
Also, it must be interesting to pursue the possibility to combine Pure Borrow and the permission model a la Linear Constraints paper.

If you feel the same, please :+1: to the Linear Constraints proposal above!

Conclusions

The paper and implementation also comes with a more sophisticated work-stealing-based quicksort example.
I’m also re-implementing e-graphs algorithms using Pure Borrow in the repository below:

Although tamagoh is not so performant yet, it contains much more involved use cases of Pure Borrow, so you can grasp the taste of Pure Borrow. It also contains experiments around new interfaces for more involvd borrow division and effectful cloning.

The latter half of the paper discusses the theoretical foundation, and sketches the proof of the type safety, purity and leak-freedom of Pure Borrow.
Although it is not spelled out rigorously, we believe our work bring new insights into this area.

If curious, please read our paper and implementation. Happy Linear Haskelling, and perhaps see you guys in Boulder!

38 Likes

… And now I think it might be also good to show the flavour of work-stealing version implementation of quicksort. Here it is:

qsortDC' ::
  (Ord a, Copyable a) =>
  -- | Threshold for the length of vector to switch to sequential sort.
  Int ->
  DivideConquer α Pair (LV.Vector a)
qsortDC' thresh =
  DivideConquer
    { divide = \vs ->
        case LV.size vs of
          (Ur n, v)
            | n <= 1 ->
                v `lseq` Control.pure Done
            | n <= thresh ->
                Done Control.<$ LV.qsort 0 v
            | otherwise -> Control.do
                let i = n `quot` 2
                (Ur pivot, v) <- LV.copyAtMut i v
                (lo, hi) <- LV.divide pivot v 0 n
                Control.pure $ Continue $ Pair lo hi
    }

It is just a rewritten version of qsort in the post, but now defined in terms of DivideConquer datatype:

data DivideConquer α t a = DivideConquer
  { divide :: forall β. (α >= β) => Mut β a %1 -> BO β (Result β t a)
  }

data Result β t a = Done | Continue (t (Mut β a))

That is, it just encodes the divide-and-conquer nature within general API, using BO-monad inside each step.
Actual computation can now be done by calling divideAndConquer combinator on it:

qsortDC ::
  (RandomGen g, Ord a, Copyable a, α >= β) =>
  -- | The # of workers.
  Int ->
  -- | Threshold for the length of vector to switch to sequential sort.
  Int ->
  g ->
  Mut α (LV.Vector a) %1 ->
  BO β (Mut α (LV.Vector a))
qsortDC nwork thresh = divideAndConquer nwork (qsortDC' thresh)

divideAndConquer ::
  forall α β t a g.
  (RandomGen g, Data.Traversable t, Consumable (t ()), α >= β) =>
  -- | The # of workers.
  Int ->
  DivideConquer α t a ->
  g ->
  Mut α a %1 ->
  BO β (Mut α a)

Under the hood, divideAndConquer uses some unsafe constructs, such as concurrent queues, but it hides its implementational detail behind the BO-based API, and user can describe their divide-and-conquer algorithm purely with BO.
Although the scheduler logic of divideAndConquer still has lot of room of improvements, but it seems quite promising example of applicability of Pure Borrow :slight_smile:

8 Likes

As a Haskeller, I’ve always been interested in the guarantees that Rust has with regards to data control and flow, and since Linear Types I’ve been excited to see where we could go next.

This post is an excellent demonstration of a potential future for this style, and while I don’t think I’ve got any use case for it in the near future, I hope to find one!

Out of interest, do you think that Pure Borrow could have performance benefits, or is the focus on safety which allows us to go faster?

6 Likes

This post is an excellent demonstration of a potential future for this style, and while I don’t think I’ve got any use case for it in the near future, I hope to find one!

Thanks! We are really encouraged by your comment :+1: :folded_hands:

Out of interest, do you think that Pure Borrow could have performance benefits, or is the focus on safety which allows us to go faster?

Good question! The current primal focus of Pure Borrow is on the purity and safety, I think there are many cases where Pure Borrow can also contribute to the performance, just as with ST-monads. Pure Borrow morally enjoys all the feature provided by ST-monad, including pure mutable reference and mutable vectors. With the help from linear types, Pure Borrows has parallelism in addition, and borrows allow us fine-grained R/W permission management. In other words, Pure Borrow would expand the range of the fast destructive programs that can be written as a pure function. In this sense, Pure Borrow can contribute the performant programming in Haskell, I think :slight_smile:

4 Likes

Is it on hackage anywhere?

1 Like

We definitely plan the release after the fixing the bug with GHCi. So stay tuned!

5 Likes

This is very cool! I’ll read carefully soon.

I think I’ve fixed this bug with linear types in GHCi. It should be fixed in 9.14 and 9.12.3.
Here are some references:

Hope that helps!

6 Likes

This is very cool! I’ll read carefully soon.

Thanks! Your comments always encourages me :slight_smile:

I think I’ve fixed this bug with linear types in GHCi. It should be fixed in 9.14 and 9.12.3.

Wow, I am not aware of that - indeed, I tested GHCi repl only with GHC 9.10.3 and 9.12.2.
And the code now runs successfully with GHC 9.12.3! Thank you for pointing it out, I’ve just edited the mentioned note accordingly :heart_eyes:
So, I think we can prepare for the Hackage release soon!

8 Likes

@konn Thanks for the announcement!

Hello! I am Yusuke Matsushita https://shiatsumat.github.io/, the lead author of the Pure Borrow paper.

I love both Haskell and Rust. I learned Haskell in high school, which led me to become a PL researcher. I started my career with Rust research, but I had long wished to contribute to Haskell.

So I am delighted to publish this work, which shows a new connection between the two languages. I hope you enjoy it. Happy Haskelling!

18 Likes

Note that GHC 9.12.3 has a different unrelated bug, so you might want to check with 9.14.1 as it’s probably the only supported version that works for you.

1 Like

Thanks! Indeed, we’ve already checked against both 9.12.4 and 9.14.1, both of them worked fine, both compiled and interpreted. Anyway, as far as our tsest suite tells, GHC 9.10.2+ works fine if used compiled, so we don’t worry too much about the supported version ranges.

3 Likes

What is the hackage release date?

No particular date in our mind, but I expect within a few weeks. Stay tuned! :folded_hands:

1 Like

For those who are curious: we are finalising our implementation for the Hackage release in this PR for past few days: Preparation for Hackage relase by konn · Pull Request #10 · SoftwareFoundationGroupAtKyotoU/pure-borrow · GitHub . We are not idling :slight_smile:

4 Likes

At long last, we’ve just made the first Hackage release :tada:

8 Likes