-
Notifications
You must be signed in to change notification settings - Fork 13
Qi Meeting Sept 13 2024
Qi Meeting Sept 13 2024
Adjacent meetings: Previous | Up | Next
We looked at some recently discovered issues with bindings to see if they might be resolved with the recent release of a new version of Syntax Spec, and learned more about what's going on there.
We discovered a few cases where bindings in Qi were behaving unexpectedly.
Michael submitted a PR to upgrade Qi to Syntax Spec v2 earlier this week.
Bindings are implemented in Qi using the "lifting lets" approach of introducing a wrapping let
form and then assigning the variable at the appropriate place using set!
.
The sole change in Qi's use of Syntax Spec was this:
- #:binding {(bind v) nested}
+ #:binding (scope (bind v) nested)
Sid was assuming it was purely a syntax change but Dominik's spidey senses, primed through decades in the security field, picked up that there was likely more behind the change. So we decided to try out the new branch and see if, indeed, it had any effect on the bindings issues.
The issue records this behavior:
flow.rkt> (~>> (odd?) (as p) (range 10) (filter p))
; /Users/siddhartha/work/lisp/racket/qi/qi-test/tests/flow.rkt:11:16: let: duplicate identifier
; at: p
; in: (let ((p undefined) (p undefined) (p undefined)) (qi0->racket (thread (thread (esc (λ (p2) (set! p p2))) ground) (esc ((λ (v) (λ rest (apply v 10 (append rest (list))))) (contract (->* (real?) (real? real?) any) (range->cstream-prepare (cstream-next->li...
We tried this expression now and found that, indeed, it produces the expected result of '(1 3 5 7 9)
!
Hooray!
Alright, how about the next one.
flow.rkt> (~> (odd?) (as p) (range 10) △ (pass p))
; filter: contract violation
; expected: (any/c . -> . any/c)
; given: #<undefined>
We found that this still happens. Investigating, we saw that this was the Racket runtime for pass
:
#'(curry filter-values (qi0->racket onex))
… where onex
is p
in this case. The entire eariler expression expands to:
(#%app
(let-values (((p) undefined))
(#%app
compose
(#%app curry filter-values p)
(qi0->racket sep)
(qi0->racket
(#%blanket-template
((#%host-expression range) __ (#%host-expression 10))))
(qi0->racket
(thread (esc (λ (p1:45) (set! p p1:45))) ground))))
odd?)
As curry
evaluates its arguments immediately, it ends up evaluating p
at compile time when the p
has not yet been assigned to the runtime input odd?
. We've encountered problems like this before with the use of curry
, and our usual fix has been to replace it with a lambda
, which evaluates its body when it is actually invoked at runtime. We replaced the implementation with this:
#'(λ args
(apply filter-values
(qi0->racket onex)
args))
And that fixed the issue!
Ben writes in with this correction:
p
is a runtime value (and so iscurry
here), so I don't think "evaluatingp
at compile time" is the right phrasing. (For example:raco make
on a file with the bad expression shouldn't error.)
What sounds more precise to me is that
curry
(through#%app
) is eager at runtime, so when evaluating the procedure to apply toodd?
, the application ofcurry
evaluatesp
before the composed function (call itset-p
) is ever called.
You could actually keep
curry
here:p
(onex
) is the only expression that needs delayed:#'(curry filter-values (λ args (apply (qi0->racket onex) args)))
Alright, next.
flow.rkt> (~> (odd?) (as p) p)
; compose: contract violation
; expected: procedure?
; given: #<undefined>
; argument position: 1st
; other arguments...:
; #<procedure:composed>
What we are expecting here is instead an arity error, as this expression is equivalent to this one:
(~> () odd?)
which is just:
(odd?) ;=> odd?: arity mismatch;
We discovered that the unexpected error in the original expression was due to its expanding to the following:
(#%app
(let-values (((p) undefined))
(#%app
compose
(#%host-expression p)
(qi0->racket
(thread (esc (λ (p1:20) (set! p p1:20))) ground))))
odd?)
The #%host-expression
is expanded by Syntax Spec (at least in this instance) simply by unwrapping the host expression:
(#%app
(let-values (((p) undefined))
(#%app
compose
p
(qi0->racket
(thread (esc (λ (p1:20) (set! p p1:20))) ground))))
odd?)
As a result, at this stage we can see that, due to Racket's left-to-right evaluation order, the identifier p
is evaluated to undefined
before the set!
is evaluated.
Ben writes in:
The
set!
won't be evaluated until the entire composed function is applied, so while the last clause is correct, I'd say it's not because of left-to-right order but because#%app
evaluates all its arguments. Here, that'scompose
, then,p
and thenset-p
(not(set-p odd?)
). Evaluatingp
yields undefined; evaluatingset-p
(if it happened) should produce something equivalent to evaluating(λ (x) (set! p x))
.
If the
compose
succeeds, then that whole thing can be applied toodd?
. I think in both cases the following would demonstrate the error, which shows that it happens at the point of constructing the procedure (flow) rather than at the point of applying it.(flow (~> (as p) (range 10) sep (pass p))) (flow (~> (as p) p))
In this case, I wonder why a Qi-introduced binding becomes a
#%host-expression
? If it were in Qi's control, delay via lambda would solve the problem (again).
It does raise a performance question of adding all the
apply
, though.
One option we considered was to come up with some form of lazy compose
, so that the bindings are not prematurely evaluated. We could potentially wrap things in lambdas to achieve this, but we didn't immediately think of how. There's also an approach (a standard approach?) where we could rewrite the compose
as a series of wrapping let
's, and name intermediate values using temporaries. But this approach would not be able to handle an arbitrary number of values between component functions.
We tried writing an rcompose
function that composes functions right-to-left by first reversing them:
(define (rcompose . args)
(apply compose (reverse args)))
And rewrote the threading rule from:
[((~or* (~datum ~>) (~datum thread)) onex:clause ...)
#`(compose . #,(reverse
(syntax->list
#'((qi0->racket onex) ...))))]
to
[((~or* (~datum ~>) (~datum thread)) onex:clause ...)
#`(rcompose . #,(syntax->list
#'((qi0->racket onex) ...)))]
so that we don't reverse the functions beforehand, to begin with.
But we discovered that even this version was evaluating the binding prematurely.
This case works already:
(~> (odd?) (as p) (gen p)) ;=> #<procedure:odd?>
And we realized it's because gen
is already lazy as it compiles to a lambda:
[((~datum gen) ex:expr ...)
#'(λ _ (values ex ...))]
On the other hand, #%host-expression
simply unwraps the contained expression and it is evaluated immediately, and that's why using the function-valued binding as a flow doesn't work, even though gen
-ing this function-valued binding does.
One resolution could be to say that we aren't able to handle this because #%host-expression
is opaque from Qi's perspective and doesn't differentiate between Qi bindings and arbitrary host expressions. So, potentially, if we had a way to recognize such bindings, they could be tagged using a #%dsl-binding
form instead, which we could perhaps compile to a lambda so that it's lazily evaluated.
We discussed that compose
is in general naive and therefore slow. Thus, we should be able to improve performance of threading flows in the compiler by exploiting information we might have, such as arity of the component functions.
(Some of these are carried over from last time)
- Resolve the issue with bindings being prematurely evaluated.
- Implement more fusable stream components like
append
,drop
, andmember
. - Provide a runtime and indicate
floe
positions by enriching the syntax of#%deforestable
- Implement the generic scheme for promoting position-oriented Qi macro definitions to handle templates
- Update the benchmarking infrastructure (
vlibench
and also the basic ones inqi-sdk
) - Define an extension scheme for deforesting custom list APIs.
- Fix the bug in using bindings in deforestable forms like
range
- Finalize and document guiding principles for Git commits in the developer wiki
- Revisit extending deforestation to multi-valued settings.
- Write a proof-of-concept compiling Qi to another backend (such as threads or futures), and document the recipe for doing this.
- Ask Ben to review tests from the perspective of Frosthaven Manager.
- Review Cover's methodology for checking coverage.
- Document the release model in the user docs and announce the performance regression (and the remedy) to users.
- Improve unit testing infrastructure for deforestation.
- Discuss and work out Qi's theory of effects and merge the corresponding PR.
- Decide on appropriate reference implementations to use for comparison in the new benchmarks report and add them.
- Deforest other
racket/list
APIs viaqi/list
- Decide on whether there will be any deforestation in the Qi core, upon
(require qi)
(without(require qi/list)
) - Continue investigating options to preserve or synthesize the appropriate source syntax through expansion for blame purposes.
Dominik, Sid
Home | Developer's Guide | Calendar | Events | Projects | Meeting Notes