Skip to content

Commit 8dea8d8

Browse files
committed
wip
1 parent fbff10a commit 8dea8d8

11 files changed

Lines changed: 665 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ var eval = braintrust.<String, String>evalBuilder()
8282
(expected, result) -> expected.equals(result) ? 1.0 : 0.0))
8383
.build();
8484
var result = eval.run();
85+
// TODO: document the concurrency contract - cases run 10-at-a-time by default;
86+
// task/scorers must be thread-safe. See Eval.Builder#maxConcurrency and #executor.
8587
System.out.println("\n\n" + result.createReportString());
8688
```
8789

braintrust-sdk/src/main/java/dev/braintrust/eval/Classifier.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* @param <INPUT> type of the input data
1515
* @param <OUTPUT> type of the output data
1616
*/
17+
// TODO: document the concurrency contract - Eval invokes this from multiple threads
18+
// (see Eval.Builder#maxConcurrency), so implementations must be thread-safe.
1719
public interface Classifier<INPUT, OUTPUT> {
1820
String INVALID_CLASSIFICATION_MESSAGE =
1921
"When returning structured classifier results, each classification must be a non-empty"

braintrust-sdk/src/main/java/dev/braintrust/eval/Dataset.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ default void forEach(Consumer<DatasetCase<INPUT, OUTPUT>> consumer) {
3232
}
3333
}
3434

35+
// TODO: document the concurrency contract - Eval drains a cursor from a single coordinator
36+
// thread even when evaluating cases concurrently, so implementations need no locking.
3537
@NotThreadSafe
3638
interface Cursor<CASE> extends AutoCloseable {
3739
/**

braintrust-sdk/src/main/java/dev/braintrust/eval/Eval.java

Lines changed: 219 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
import io.opentelemetry.api.trace.SpanKind;
1818
import io.opentelemetry.api.trace.StatusCode;
1919
import io.opentelemetry.api.trace.Tracer;
20+
import io.opentelemetry.context.Context;
2021
import java.util.*;
22+
import java.util.concurrent.Executor;
23+
import java.util.concurrent.ExecutorService;
24+
import java.util.concurrent.Executors;
25+
import java.util.concurrent.Semaphore;
26+
import java.util.concurrent.atomic.AtomicInteger;
2127
import java.util.function.Function;
2228
import javax.annotation.Nonnull;
2329
import javax.annotation.Nullable;
@@ -26,11 +32,20 @@
2632
/**
2733
* An evaluation framework for testing AI models.
2834
*
35+
* <p><b>Cases are evaluated concurrently.</b> By default up to {@value #DEFAULT_MAX_CONCURRENCY}
36+
* cases run at once, so the {@link Task}, {@link Scorer}s and {@link Classifier}s supplied to an
37+
* eval must be safe to invoke from multiple threads. Use {@link Builder#maxConcurrency(int)} to
38+
* change the bound, or {@code maxConcurrency(1)} to evaluate cases one at a time. Use {@link
39+
* Builder#executor(Executor)} to supply the threads the cases run on.
40+
*
2941
* @param <INPUT> The type of input data for the evaluation
3042
* @param <OUTPUT> The type of output produced by the task
3143
*/
3244
@Slf4j
3345
public final class Eval<INPUT, OUTPUT> {
46+
/** Default number of eval cases evaluated concurrently. */
47+
public static final int DEFAULT_MAX_CONCURRENCY = 10;
48+
3449
private static final AttributeKey<String> PARENT =
3550
AttributeKey.stringKey(BraintrustTracing.PARENT_KEY);
3651
private final @Nonnull String experimentName;
@@ -47,6 +62,8 @@ public final class Eval<INPUT, OUTPUT> {
4762
private final @Nonnull Map<String, Object> metadata;
4863
private final @Nonnull Parameters parameters;
4964
private final boolean ensureNew;
65+
private final int maxConcurrency;
66+
private final @Nullable Executor executor;
5067

5168
private Eval(Builder<INPUT, OUTPUT> builder) {
5269
this.experimentName = builder.experimentName;
@@ -65,11 +82,39 @@ private Eval(Builder<INPUT, OUTPUT> builder) {
6582
this.metadata = Map.copyOf(builder.metadata);
6683
this.parameters = builder.buildParameters();
6784
this.ensureNew = builder.ensureNew;
85+
this.maxConcurrency = builder.maxConcurrency;
86+
this.executor = builder.executor;
6887
}
6988

70-
/** Runs the evaluation and returns results. */
89+
/**
90+
* Runs the evaluation to completion and returns the results.
91+
*
92+
* <p>Cases are evaluated concurrently; see {@link Builder#maxConcurrency(int)}.
93+
*/
7194
public EvalResult run() {
72-
try (var cursor = dataset.openCursor()) {
95+
var result = start();
96+
result.awaitCompletion();
97+
return result;
98+
}
99+
100+
/**
101+
* Creates the experiment and begins evaluating cases in the background, returning as soon as
102+
* the experiment exists.
103+
*
104+
* <p>The returned {@link EvalResult} carries the experiment id, name and url immediately — so
105+
* callers can surface the link right away — while its cases are still being evaluated. Use
106+
* {@link EvalResult#isDone()} and {@link EvalResult#awaitCompletion()} to observe the run.
107+
*
108+
* <p>Errors raised while creating the experiment are thrown from this method. Errors raised
109+
* once the run is underway are reported through {@link EvalResult#awaitCompletion()}.
110+
*/
111+
public EvalResult start() {
112+
var state = new EvalRunState();
113+
var cursor = dataset.openCursor();
114+
final EvalResult result;
115+
final ExecutorService ownedExecutor;
116+
final Executor caseExecutor;
117+
try {
73118
Optional<String> datasetVersion = Optional.empty();
74119
Optional<String> datasetId = Optional.empty();
75120
if (dataset instanceof DatasetBrainstoreImpl<INPUT, OUTPUT>) {
@@ -94,8 +139,6 @@ public EvalResult run() {
94139

95140
var experiment = new ExperimentsApi(client).postExperiment(createExperiment);
96141

97-
cursor.forEach(datasetCase -> evalOne(experiment.getId().toString(), datasetCase));
98-
99142
// Use the experiment's actual name from the response: with ensure_new the backend may
100143
// dedupe a conflicting name (e.g. "foo" -> "foo-2f8ca776"), and the URL must point at
101144
// the real, created experiment.
@@ -109,11 +152,143 @@ public EvalResult run() {
109152
project.getName())
110153
.toASCIIString(),
111154
resolvedName);
112-
return new EvalResult(experiment.getId().toString(), resolvedName, experimentUrl);
155+
result =
156+
new EvalResult(
157+
experiment.getId().toString(), resolvedName, experimentUrl, state);
158+
159+
if (executor != null) {
160+
ownedExecutor = null;
161+
caseExecutor = executor;
162+
} else {
163+
ownedExecutor = createDefaultExecutor();
164+
caseExecutor = ownedExecutor;
165+
}
166+
} catch (Throwable t) {
167+
cursor.close();
168+
throw t;
169+
}
170+
171+
// Each case re-establishes the context that was current when the run was started. The eval
172+
// span itself is created with setNoParent(), so this carries baggage rather than parentage.
173+
var callerContext = Context.current();
174+
var experimentId = Objects.requireNonNull(result.getExperimentId());
175+
var coordinator =
176+
new Thread(
177+
() ->
178+
evalAllCases(
179+
cursor,
180+
caseExecutor,
181+
ownedExecutor,
182+
experimentId,
183+
callerContext,
184+
state),
185+
"braintrust-eval-coordinator");
186+
coordinator.setDaemon(true);
187+
coordinator.start();
188+
return result;
189+
}
190+
191+
/**
192+
* Drains the dataset cursor on this (coordinator) thread, submitting each case to {@code
193+
* caseExecutor}. A semaphore bounds the number of cases in flight so the whole dataset is never
194+
* materialized in memory, and so the executor's queue can't grow without limit.
195+
*
196+
* <p>Runs on the coordinator thread, never on a worker: waiting for cases to finish from inside
197+
* the pool that runs them would deadlock once the pool is saturated.
198+
*/
199+
private void evalAllCases(
200+
Dataset.Cursor<DatasetCase<INPUT, OUTPUT>> cursor,
201+
Executor caseExecutor,
202+
@Nullable ExecutorService ownedExecutor,
203+
String experimentId,
204+
Context callerContext,
205+
EvalRunState state) {
206+
var inFlight = new Semaphore(maxConcurrency);
207+
Throwable fatal = null;
208+
try (cursor) {
209+
for (var next = cursor.next(); next.isPresent(); next = cursor.next()) {
210+
var datasetCase = next.get();
211+
inFlight.acquire();
212+
try {
213+
caseExecutor.execute(
214+
() -> {
215+
try (var unused = callerContext.makeCurrent()) {
216+
if (evalOne(experimentId, datasetCase)) {
217+
state.caseSucceeded();
218+
} else {
219+
state.caseFailed();
220+
}
221+
} catch (Throwable t) {
222+
// Contain the failure to this case: one bad case must not
223+
// abort the rest of the run.
224+
state.caseFailed();
225+
log.warn(
226+
"Eval case failed for input: {}",
227+
datasetCase.input(),
228+
t);
229+
} finally {
230+
inFlight.release();
231+
}
232+
});
233+
} catch (RuntimeException e) {
234+
// e.g. RejectedExecutionException from a caller-supplied executor. Release the
235+
// permit we took so the drain below can't hang.
236+
inFlight.release();
237+
throw e;
238+
}
239+
}
240+
} catch (InterruptedException e) {
241+
Thread.currentThread().interrupt();
242+
fatal = e;
243+
} catch (Throwable t) {
244+
fatal = t;
245+
} finally {
246+
// Wait for every in-flight case, including when the drain above aborted.
247+
try {
248+
inFlight.acquire(maxConcurrency);
249+
} catch (InterruptedException e) {
250+
Thread.currentThread().interrupt();
251+
if (fatal == null) {
252+
fatal = e;
253+
}
254+
}
255+
if (ownedExecutor != null) {
256+
ownedExecutor.shutdown();
257+
}
258+
// TODO: force-flush spans here so that completion means "results are in Braintrust".
259+
// Needs a blocking flush on the eval's OpenTelemetry instance —
260+
// BraintrustTracing.attemptForceFlush is package-private and non-blocking.
261+
state.complete(fatal);
113262
}
114263
}
115264

116-
private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase) {
265+
/**
266+
* The executor used when the caller did not supply one: a fixed pool of daemon threads sized to
267+
* {@link #maxConcurrency}, owned by this run and shut down when it finishes.
268+
*
269+
* <p>On Java 21+, pass {@code Executors.newVirtualThreadPerTaskExecutor()} to {@link
270+
* Builder#executor(Executor)} if you want to run many cases concurrently.
271+
*/
272+
private ExecutorService createDefaultExecutor() {
273+
var counter = new AtomicInteger();
274+
return Executors.newFixedThreadPool(
275+
maxConcurrency,
276+
r -> {
277+
var thread =
278+
new Thread(r, "braintrust-eval-worker-" + counter.incrementAndGet());
279+
thread.setDaemon(true);
280+
return thread;
281+
});
282+
}
283+
284+
/**
285+
* Evaluates a single case. Runs entirely on one thread so that the OpenTelemetry scopes it
286+
* opens stay thread-confined.
287+
*
288+
* @return false if the task threw (scorers fell back to {@link Scorer#scoreForTaskException}),
289+
* true otherwise
290+
*/
291+
private boolean evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase) {
117292
var rootSpan =
118293
tracer.spanBuilder("eval") // TODO: allow names for eval cases
119294
.setNoParent() // each eval case is its own trace
@@ -164,7 +339,7 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
164339
for (var scorer : scorers) {
165340
runScoreForTaskException(experimentId, rootSpan, scorer, e, datasetCase);
166341
}
167-
return;
342+
return false;
168343
}
169344
taskSpan.end();
170345
}
@@ -221,6 +396,7 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
221396
} finally {
222397
rootSpan.end();
223398
}
399+
return true;
224400
}
225401

226402
/**
@@ -406,6 +582,8 @@ public static final class Builder<INPUT, OUTPUT> {
406582
private @Nonnull List<String> tags = List.of();
407583
private @Nonnull Map<String, Object> metadata = Map.of();
408584
private boolean ensureNew = false;
585+
private int maxConcurrency = DEFAULT_MAX_CONCURRENCY;
586+
private @Nullable Executor executor;
409587

410588
public Eval<INPUT, OUTPUT> build() {
411589
if (config == null) {
@@ -453,6 +631,40 @@ public Builder<INPUT, OUTPUT> apiClient(BraintrustApiClient apiClient) {
453631
return apiClient(apiClient.openApiClient());
454632
}
455633

634+
/**
635+
* Sets the maximum number of eval cases evaluated concurrently. Defaults to {@value
636+
* Eval#DEFAULT_MAX_CONCURRENCY}.
637+
*
638+
* <p>Because cases run concurrently, the {@link Task}, {@link Scorer}s and {@link
639+
* Classifier}s must be safe to invoke from multiple threads. Pass {@code 1} to evaluate
640+
* cases one at a time.
641+
*
642+
* <p>This bounds how many cases are in flight at once. If you also supply an {@link
643+
* #executor(Executor)} with fewer threads than this, that executor is the real limit and
644+
* the remaining cases queue.
645+
*/
646+
public Builder<INPUT, OUTPUT> maxConcurrency(int maxConcurrency) {
647+
if (maxConcurrency < 1) {
648+
throw new IllegalArgumentException(
649+
"maxConcurrency must be at least 1, got " + maxConcurrency);
650+
}
651+
this.maxConcurrency = maxConcurrency;
652+
return this;
653+
}
654+
655+
/**
656+
* Sets the executor that eval cases run on. Defaults to a fixed pool of daemon threads
657+
* sized to {@link #maxConcurrency(int)}, created and shut down by the eval.
658+
*
659+
* <p>An executor supplied here is never shut down by the SDK — the caller owns its
660+
* lifecycle. On Java 21+, pass {@code Executors.newVirtualThreadPerTaskExecutor()} if you
661+
* want to run many cases concurrently.
662+
*/
663+
public Builder<INPUT, OUTPUT> executor(@Nonnull Executor executor) {
664+
this.executor = Objects.requireNonNull(executor);
665+
return this;
666+
}
667+
456668
public Builder<INPUT, OUTPUT> tracer(Tracer tracer) {
457669
this.tracer = tracer;
458670
return this;

0 commit comments

Comments
 (0)