Re: Comparing Pika-syle and JNI-style Tom Lord 14 Jan 2004 18:40 UTC


    > From: Jim Blandy <xxxxxx@redhat.com>

    > Jim Blandy <xxxxxx@redhat.com> writes:
    >> Well, if SRFI-50 turned out not to be what I was hoping, and I didn't
    >> come to my senses quickly enough, I was going to turn <minor/minor.h>,
    >> into a .texi file, start a SRFI from that, and see what people said.

(As I said, I'm willing to work on a SRFI too -- and have Pika docs I
can contribute for that.)

    > In light of that, I'm curious to know how people generally feel
    > about the Pika vs. JNI issue.  If there's a near consensus on
    > one or the other, then that could save a lot of trouble.

    > Here's how I see it:
    [....]
    > Is this fair?  What have I missed?  What do people think?

I think it's pretty good though I do have some replies.  I'm replying
to your message a bit out of order.

    > It would be nice to see sample code in each style.  C implementations
    > of "cadr" and "assq" would be nice.  As far as I know, error checking
    > is similar under both interfaces, so that can be left out.

    > [cadr isn't very interesting, imho -- cadr example snipped]

    >     mn_ref *
    >     assq (mn_call *c, mn_ref *key, mn_ref *alist)
    >     {
    >       while (mn_pair_p (c, alist))
    >         {
    >           mn_ref *pair = mn_car (c, alist);
    >           mn_ref *pair_key = mn_car (c, pair);
    >
    >           if (mn_ref_eq (c, key, pair_key))
    >             return pair;
    >
    >           mn_free_local_ref (c, pair);
    >           mn_free_local_ref (c, pair_key);
    >           alist = mn_to_cdr (c, alist);
    >         }
    >
    >       return mn_false (c);
    >     }

Note, by the way that while my assq is arguably uglier than yours,
mine is O(1) space and yours is O(N) where N is the length of the
alist.  (Does that count as a benefit of having more hair on my assq?
(sorry :-)).

/* untested code */
t_scm_error
scm_assq (t_scm_word * answer,
          t_scm_arena instance,
          t_scm_word * key,
          t_scm_word * alist)
{
  struct assq_frame
  {
    SCM_FRAME;
    scm_word_t * pair;
    scm_word_t * key;
  } l;

  SCM_PROTECT_FRAME (l);

  scm_make_false (answer, instance);

  while (scm_is_pair (instance, alist))
    {
      scm_car (&l.pair, instance, alist);
      scm_car (&l.key, instance, &l.pair);
      if (scm_values_eq (instance, &l.key, key))
        {
          SCM_LSET (answer, instance, &l.pair);
          break;
        }
      scm_cdr (alist, instance, alist);
    }

  SCM_UNPROTECT_FRAME (l);
  return 0;
}

(I should be making a new release of Pika soon which contains lots of
example code.)

    > Commonalities:
    > - Both work by having C code manipulate only references to Scheme
    >   values, not Scheme values themselves.
    > - Both impose few restrictions on the representation of Scheme objects.
    > - Both allow GC to occur at any time.
    > - Both can be implemented in a way that interacts nicely with threads.
    > In Pika:
    > - Leaks are impossible, since references are stack-allocated.
    > - References are freed upon exit from the lexical block that owns
    >   them --- finer-grained than JNI-style.
    > - Probably less overhead than JNI-style.

All that seems right to me.

    > But:
    > - Forgetting an UNGCPRO corrupts the GC's data structures, and may
    >   fail only intermittently.  Irregular exits (return; goto; break;
    >   continue) require attention.  [...]

A lint-like program can address that issue if it proves serious.

    > 				[...] longjmp is even harder.

It's not that hard -- you just can't use "naked" longjmp to jump past
protected frames (you would have to use a `scm_setjmp' / `scm_longjmp'
pair.)  So, how much of a restriction is that?   Here's what I see:

Let's consider three classes of C function:

   SCHEME	-- functions provided by the FFI implementor
   FFI-USER	-- functions written to use the FFI
   3RD-PARTY	-- other functions, such as those in a C library
                   to which we are making bindings using the FFI

SCHEME and FFI-USER functions can simply be written to use
`scm_setjmp/scm_longjmp' rather than `setjmp/longjmp'.   There's no
problem among those.

I think it would unduly burden FFI implementors to permit naked
longjmps past SCHEME frames.   Is that a controversial belief?
In other words, suppose that we have a call-chain like:

	3RD-PARTY	-- calls setjmp
        SCHEME
        3RD-PARTY	-- calls longjmp?

In that kind of call chain I think that the longjmp should be declared
illegal by the FFI spec.

Suppose we have a call chain like this:

	FFI-USER	-- wants to fill a jmp buffer
        FFI-USER*
        3RD-PARTY	-- wants to lonjmp to the outermost

That can be accomplished by wrapping the 3RD-PARTY code:

        FFI-USER	-- uses scm_setjmp
        FFI-USER*
        WRAPPER		-- uses setjmp and scm_longjmp
        3RD-PARTY	-- uses longjmp

so that a longjmp from 3RD-PARTY to the outermost FFI-USER is
accomplished in two steps: a longjmp to WRAPPER which immediately
calls scm_longjmp.

The wrapping can work the other way too:

	3RD-PARTY	-- uses setjmp
        FFI-USER*
        FFI-USER	-- wants to use longjmp

becomes:

	3RD-PARTY	-- uses setjmp
        WRAPPER		-- uses scm_setjmp and longjmp
        FFI-USER*
        FFI-USER	-- uses scm_longjmp

The case remaining is where the FFI-USER code has no access to the
jump buffer:

	3RD-PARTY	-- uses setjmp
	FFI-USER*
	3RD-PARTY	-- uses longjmp

but that can be handled by a wrapper too:

	WRAPPER		-- creates an auxiliary stack
        3RD-PARTY	-- uses setjmp
        FFI-USER*	-- stores frames that might be lost in
                           a longjmp on the auxiliary stack
        3RD-PARTY	-- uses longjmp

which is very similar to what I would expect a JNI/Minor-style
implementation to do but only pays for the costs of the auxiliary
stack when it is actually needed.  This approach is no different from
the kind of thing you'd need to do if, for example, one of the
intermediate FFI-USER* frames wanted to open a temp file that it might
not otherwise get a chance to close.

I would not object to a Pika-style FFI which additionally contains a
standard interface to such "auxiliary stacks" -- so that authors of
FFI-USER code could make longjmp-safe functions that can be freely
combined.

Of course this case:

	FFI-USER	-- uses scm_setjmp
        3RD-PARTY
        FFI-USER	-- uses scm_longjmp

depends on the nature of 3RD-PARTY but if 3RD-PARTY can handle being
longjmped past, then at most you would add wrappers around it.

    > - Since the API functions all expect pointers to t_scm_word values,
    >   this discourages people from passing them around directly, but it
    >   can still be done --- e.g. "frame.x = frame.y;" --- and doing so
    >   will usually work.  But doing so is a bug.

I believe that we can provide a header file which will declare the FFI
types and functions in such a way that correct FFI-using code will
compile with this header (but produce nonsense code) and FFI-using
code having the kind of bug you mention will cause a compiler error.
In other words I think that we can use existing C compilers as a kind
of `lint' for that particular problem.  (It won't catch a use of
something like `memcpy' rather than C assignment, of course.)

Also, that kind of bug is again something that could be caught fairly
easily by a custom lint-like program.

    > - Variable declarations are cluttered with enclosing structs and GCPRO
    >   / UNGCPRO calls.

Tastes vary.   In isolation, at least, that seems a weak reason to use
a more costly approach.

    > - Functions may only return Scheme values by reference; they may not
    >   provide them as their (syntactic) return values.  Instead of writing
    >   "f (g (x))", you must write:

    >     g (&frame.x, &frame.temp);
    >     f (&frame.temp, &frame.temp2);

    >   In other words, you must write your code as linear series of
    >   operations which work by side-effects.

    > In JNI-style:
    > - Functions can return references directly, so code need not be
    >   linearized.  You can write "f (call, g (call, x))" --- if you know
    >   that "call" will return and free g's return value soon enough.
    > - Local references are freed automatically when the Scheme->C call to
    >   which they belong returns.  Leaks due to unfreed local references
    >   (which will probably be the most common sort of error) have a
    >   bounded and often (though not always) short lifetime.

One thing that bothers me about the JNI-style:

	f (g(x), h(y))

is the lifetime of the references returned from `g' and `h'.  If that
call is in a loop, for example, two new references will be allocated
on every iteration and, absent the programmer taking steps to
explicitly manage them (sacrificing the gain of the function call
syntax) all of those references remain live until C returns back to
Scheme.

As a side effect of that, functions which should consume O(1) space
will, in basic JNI-style, consume O(N) where N is the number of
intermediate results they create during computation.

Now I concede that for something like `cadr', such space performance
is basically inconsequential (unless `cadr' is itself called in a
loop).  But if I'm writing something more involved I'll immediately be
back to either the "linear style" of Pika or to something equally
"ugly".

This also comes back to error handling.  We haven't talked about it
much yet but my thinking is that nearly every FFI function should be
able to return an error code and nearly every call to an FFI function
should be checked.  For example, if `scm_car' is passed a non-pair
argument, I think it should be able to return an error code.  Normally
I'd expect people to write not:

	g (&frame.answer, instance, &frame.x);
        f (&frame.answer, instance, &frame.answer);

but:

	err = g (&frame.answer, instance, &frame.x);
        if (err)
          {
            ....;
          }
        err = f (&frame.answer, instance, &frame.answer);
        if (err)
          {
            ...;
          }

or

        /* We know that no errors can arise.
         */
	(void)g (&frame.answer, instance, &frame.x);
        (void)f (&frame.answer, instance, &frame.answer);

or something in between.

Now, to be sure -- we can not _require_ implementations to notice "it
is an error" conditions (such as passing an integer to CAR) -- but
since implementations are encouraged to, and since many or most
implementations do so, standardizing the C interface to such errors
seems a reasonable thing to do in an FFI.

    > - No GC data structures live on the C stack, so careless control flow
    >   and longjmps will not corrupt the GC's data structures.
    > - The "explicit free" model is familiar to C programmers.

But, again -- it's the lifetimes of intermediate results that I find
problematic (along with the costs of allocating them).  The "explicit
free" model is indeed familiar but that's not quite what you're
proposing.

    > - Variables are declared normally, and their values used directly.

Variables are declared normally in Pika, too.  I think you mean that
JNI-style attempts to disguise handles as Scheme values.  It is
because it can't pull off that illusion perfectly that I think it is a
questionable choice.

    > - Since mn_ref is an incomplete type, it can't be dereferenced, so
    >   people can't be sloppy and operate on the heap values directly.

And, again: (a) I'm fairly certain we can trick existing C compilers
into acting as a lint for that particular kind of error;  (b) an
extensible lint that can be programmed to check for those and other
kinds of errors is a tractable problem and as much of a "killer app"
as you're likely to find in the development-tools world.

    > But:
    > - The "explicit free" model is still error-prone.  The fact that leaks
    >   are bounded by their owning call's lifetime may not always
    >   help.

Indeed.

    > - Probably more overhead than Pika-style.
    > - Code will be cluttered with explicit-free crap.

-t