Recommended patterns and techniques for Seqs (especially when memoized)

I’ve been playing with lazy sequences in the context of this post (reimplementation of the Power series, power serious paper).

The performance problems I alluded to there were apparently due to nested/excessive memoziation being done (by calls to Seq.memoize). I was doing this pessimistically in an attempt to recreate the Haskell list behavior in OCaml. The ultimate fix I landed on was to implement all base operations from the ground up using non-memoized Seq.ts and then expose a wrapper at the next level to simply wrap all of the inner Seq.ts in a call to Seq.memoize.

So, for example, here’s the base interface (Series_trunc.mli):

type t = Q.t Seq.t

val coeffs : t -> Q.t Seq.t
val nth : int -> t -> Q.t
val zero : t
val one : t
val z : t
val zpow : int -> t
val neg : t -> t
val ( + ) : t -> t -> t
val ( - ) : t -> t -> t
val ( *. ) : Q.t -> t -> t
val ( * ) : t -> t -> t
val ( / ) : t -> t -> t
val compose : t -> t -> t

And here’s the memoized wrapper (Series_memo.ml):

type t = Q.t Seq.t

let coeffs fs = fs

(* NOTE: We explicitly do NOT memoize anything we know to have O(1) terms. *)
let nth = Series_trunc.nth
let zero = Series_trunc.zero
let one = Series_trunc.one
let z = Series_trunc.z

(* TODO: Consider memoizing iff n exceeds some threshold. *)
let zpow n = Series_trunc.zpow n |> Seq.memoize
let neg t = Series_trunc.neg t |> Seq.memoize
let ( + ) fs gs = Series_trunc.(fs + gs) |> Seq.memoize
let ( - ) fs gs = Series_trunc.(fs - gs) |> Seq.memoize
let ( *. ) c fs = Series_trunc.(c *. fs) |> Seq.memoize
let ( * ) fs gs = Series_trunc.(fs * gs) |> Seq.memoize
let ( / ) fs gs = Series_trunc.(fs / gs) |> Seq.memoize
let compose fs gs = Series_trunc.compose fs gs |> Seq.memoize

You can see the rest of the implementation here.

Is there a better way of doing this? In general, what are the pitfalls that one should avoid while working with memoized Seqs? I’m not sure exactly what was going on with my original implementation (I no longer have that code and went through a lot of iterations), but I suspect that the problem was that I was double-memoizing many internal Seqs and causing extra memory pressure due to this. Obviously (based on empirical performance) the top-level memoziation is better, but it’s not clear if this is the best I can do. It is nearly certain that some extra memoization is happening where it doesn’t need to happen, and also that some recursive operations in the base implementation are not memoized but should be.

The fundamental issue is that there is no memoization by default (as in Haskell lists). As a result, only explicitly memoized lists are retained. It looks like under the hood, this gets translated into a call to Lazy.from_fun. Even if that function did explicitly avoid double-wrapping lazy results, I suspect that any levels of indirection would prevent nested lazy cells from being collapsed.

Can anybody point me to reliable patterns for avoiding this issue with lazy sequences, or is the general advice simply to ignore lazy/memoized values in general? (That’s the gist I was getting from the other thread).

Beyond memoization per se, I’m not happy with how I ended up “manually” constructing the recursive sequence parts by helper functions (e.g., see here). I suspect there is a more idiomatic way to do this using the various Seq combinators, but I couldn’t figure out which if so. In this case, I’m essentially popping a single element off of the heads of two lists and then constructing the tail as a recursive expression possibly involving the head and tails of two lists. This is more general than a simple operation like map2, etc., but it also requires in some cases extending the list beyond the length of the shortest of the two Seqs.

(Implementation note: I ended up implementing this differently than the original Haskell version due to performance reasons. Many operations end up exponential in the number of sequences you are combining, so you can dramatically speed up computation by truncating sequences at some upper bound; this allows you to propagate zeros indefinitely past that point and, in many cases, terminate early. It adds a bit of complexity to the implementation but yields big wins in runtime.)

After doing some test runs with the composition operator, I think I’m not actually memoizing enough here. I’m just not sure how to get more structural sharing of the various tails that get generated during the traversal. As I noted above, part of the problem is the “indirection” between the base Seq.t itself and its memoized version. Perhaps I’m not thinking of this correctly. Can I get what I want by throwing in a lazy cell somewhere? And if so, where?

I would not have memoized zpow.

Regarding your question “Is it better to return the original fs or gs sequence below rather than reconstructing it?”, yes it is better to return the original value. (That is a generally valid advice, not even specific to your use case.) But notice that you have an issue here: you will end up memoizing something that is potentially already memoized (or deemed to not be worth memoizing). So, you are better handling laziness yourself rather than relying on Seq.memoize to do it.

Finally, as you already noted, there is an issue with compose, as it will end up computing go fs' gs again and again. Its result should be memoized.

1 Like

How would you propose that I handle laziness myself? I’m having a hard time figuring out exactly where to put the lazy cell to get the desired behavior without double-wrapping. Just around the head, just around the tail, both, or around the entire Seq?

Something like that (completely untested):

let rec add fs gs =
  let value =
    lazy Seq.(
      match fs (), gs () with
      | Cons (f, fs'), Cons (g, gs') -> Cons (f + g, add fs' gs')
      | (Cons _ as fs), Nil -> fs
      | Nil, (Cons _ as gs) -> gs
      | Nil, Nil -> Nil) in
  fun () -> Lazy.force value
1 Like

Thanks, that works great! I didn’t consider making the outer sequence value itself lazy. I also wouldn’t have considered the pattern of explicitly tying the uncons operation directly to Lazy.force, but this makes sense.

Anyway, it turns out that the compose operator is the least of my concerns–the powerset and multiset constructions (in the context of analytical combinatorics) turn out to be non-trivial to implement for unbounded sequences. That’s another topic for another day. :slight_smile:

You might be interested in <https://github.com/c-cube/oseq/>. I don’t recall if there’s multiset stuff but it should have combinations at least, and a memorization operator.

1 Like

@dbuenzli once mentioned that use of Seq in a code base is a code smell and that there’s likely a better way to accomplish the task than using Seq. @dbuenzli Do you still feel the same, and if so, why?

I’m very curious to hear why. Specifically, whether these hang ups are around specific API design decisions in Seq itself or whether this is due to an opposition to the notion of lazy lists in general. FWIW, I do find the API a bit strange because it almost suggests an imperative/nondeterministic sequence due to the unit -> 'a signature. I suspect that in most cases, sequences are backed by a deterministic/reiterable implementation. Otherwise, why bother with a sequence abstraction at all? You might as well just provide some unit-callback in whatever context the Seq appears. Perhaps @c-cube can provide more color the decisions of the API shape, but my understanding was that this was more performant than alternatives considered (and wrapping everything in a lazy forces users into memoization).

The stereotypical situation where I find myself using Seq is when I have some computation that is naturally expressed as some recursion (or loop) and I want to share/reuse that computation in a “suspended” fashion. My only complaint with the Seq API is that it requires you to drop into continuation-passing style. This is why I’m so excited about the possibility of first-class (or at least ergonomic) generators.

You can read the two PRs that lead to Seq.
<https://github.com/ocaml/ocaml/pull/635>
<https://github.com/ocaml/ocaml/pull/1002>

In essence, Seq is the design that got merged, after failing to agree on basically every other design first over the years. It works for mutable and immutable sources and it’s simple. I sometimes wish the design was different, but it’s better to have something than nothing and we failed to do better (in terms of performance, mostly).

1 Like

Seqs are fine and natural for certain things.

They are just not a very good tool for the reason they have been introduced to in the stdlib which was something like “provide a uniform interface to enumerate the elements of containers”. So when I see them used in that context I find them a smell. First due to life times and mutation they are not a very good enumerator for imperative datastructures which the stdlib is littered with. Second what one is seeking to do is very often more efficiently done via an iter or a fold. This is exactly what happend when I made the comment you mentioned:

I’m not sure exactly how came up with the idea of converting to maps, partition them and then iterate over them with Seqs (Seqs are always code smells). At that point you have gone twice over the member of your archive, have sorted its paths twice, redone the map twice, and generated a lot of gc garbage with totally useless Seqs. Intead of a simple Zipc.fold.

I can see why people reach for them (direct style) but I think that most of the time code is better off if people learn to fold.

2 Likes

For that use case I still favor my iter package, which is fairly fast (at least with flambda) and generally reads better than folds as soon as things go beyond a simple traversal :slightly_smiling_face:. It’s compatible with stdlib containers, too (but it’s push, not pull, so slightly less general than Seq).

Seq is also nice for idiomatically implementing things like Traversable, which is not a common idiom in OCaml. In fact, I find Seq to be a nice atomic primitive given that operations are generally ad hoc in OCaml. The issue is that you–as a data structure author–don’t assert up-front which interfaces your structure implements but do so implicitly through the functions you happen to implement. And because those are not stated explicitly, it can often be difficult to generalize or recognize patterns without having seen them called out elsewhere. Case in point: much of the vocabulary and common interfaces used in modern OCaml originated in Haskell. This is all to say that I don’t think it’s immediately obvious that one is better than the other. The fact that–as you call out–translation to-and-from Seq generates a lots of intermediate garbage is an “implementation detail”. On the other hand, something that I appreciate about OCaml (and the OCaml community) is the attention to detail that can have a material impact on runtime properties.

Edit: that was meant to be a response to @dbuenzli l, but I think I clicked the wrong button.